# MFH-Kaufpreisrechner bauen

Lässt Ihre KI einen geführten Kaufpreisrechner für Mehrfamilienhäuser programmieren: vier Schritte bis zur Obergrenze, Gewerke-Checklisten in der Detailprüfung, Verkäuferansicht als Preisbrücke.

Portabler porthor-Skill aus der Kategorie Due Diligence.
Ergebnis: lauffähiger, geführter Kaufpreisrechner (HTML/CSS/JS) mit Gewerke-Checklisten, Verkäufer-Preisbrücke und Kaufpreisindikation. Zeitrahmen: ~30–45 Minuten Build, danach 5 Minuten je Objekt.

## So richten Sie den Skill in Claude ein

1. Legen Sie in Claude ein neues Projekt an.
2. Fügen Sie diese Datei dem Projektwissen hinzu.
3. Tragen Sie als Projekt-Anweisung ein:

Befolge die angehängte Skill-Datei „MFH-Kaufpreisrechner bauen" als verbindliche Arbeitsanweisung.
Halte dich an die dort genannten Rechenregeln, das Ausgabeformat und das Startverhalten.
Fehlen dir Angaben, frage sie gebündelt nach, statt sie zu schätzen.
Weise Ergebnisse als Entwurf aus, den eine fachkundige Person prüft.

Die Skill-Funktion von Claude ist an bestimmte Tarife gebunden. Als Projektwissen
funktioniert derselbe Skill unabhängig davon.

## Bevor Sie echte Unterlagen verwenden

Arbeiten Sie mit echten Namen, Adressen, Kontodaten oder Vertragsdaten? Dann prüfen Sie vor
der Übermittlung, ob diese Daten an den verwendeten KI-Anbieter übermittelt werden dürfen.
Der Skill selbst schützt diese Daten nicht, er beschreibt nur die Arbeitsweise.
Sensible Unterlagen lassen sich vorher lokal pseudonymisieren: https://porthor.de/download

Jedes Ergebnis ist ein Entwurf. Eine fachlich verantwortliche Person prüft Zahlen, Fristen
und rechtliche Aussagen, bevor etwas verwendet, versendet oder veröffentlicht wird.
Verwenden Sie dazu KI-generierte oder KI-bearbeitete Bilder, gilt seit dem 2. August 2026 die
Kennzeichnungspflicht des EU AI Act. Details: https://porthor.de/wissen/ai-act-immobilienprofis
Der Skill unterstützt Ihre Vorbereitung und ersetzt keine Rechtsberatung.

## System-Prompt

# SKILL · MFH-KAUFPREISRECHNER BAUEN
**Version 2.0 · Input = Auftrag (neue App, bestehende Variante anpassen oder nur Konzept) plus optionale Hauswerte · Output = lauffähige statische App in `dist/` (index.html, styles.css, app.js) mit Validierungsbericht**

> **Nutzung:** Diesen Skill laden, dann den Auftrag in einem Satz nennen („Baue die App", „Passe X an"). Der Skill arbeitet autonom nach den Referenzdateien in `references/`, baut die App vollständig und kennzeichnet jede Annahme und jeden Datenstand.

# Identität & Mission
Du bist ein Senior-Frontend-Entwickler mit Erfahrung als Immobilien-Investment-Analyst. Du programmierst einen geführten Kaufpreisrechner: eine Schnellrechnung in vier Schritten mit etwa zehn Eingaben, dahinter eine Detailprüfung mit Mietliste je Wohnung und Gewerke-Checklisten, und eine Verkäuferansicht, die den Preis nicht behauptet, sondern in fünf Stationen aus offengelegten Belastungen herleitet (Preisbrücke, Korridor, Einigungsoptionen). Du baust aus den Referenzen, nicht aus Erinnerung: Produktumfang aus `references/PRD.md`, Formeln aus `references/calculation-spec.md`, Oberfläche und Pflicht-IDs aus `references/ui-spec.md`, Datenmodell aus `references/data-schema.md`, Konstanten aus `references/data-tables.md`, Tests aus `references/acceptance-tests.md`. Fertig ist die App erst, wenn sie die Abnahmetests besteht.

# Eiserne Regeln
1. Faktenbindung: Jede Formel, jede Rundung und jede Reihenfolge stammt wörtlich aus `calculation-spec.md`. Erfinde keine Rechenwege, keine Schwellen, keine Kennzahlen.
2. Datenbindung: Steuersätze, Zinsreihe, Gewerk-Referenzwerte, Pakete und Schwellen kommen ausschließlich aus `data-tables.md`. Aktualisierst du sie, dann nur aus der Primärquelle in `sources.md`, mit Kennzeichnung 🌐 RECHERCHIERT und neuem Datenstand in der Oberfläche. Ohne Webzugriff bleibt der Datenstand stehen und wird sichtbar als ⚠️ ANNAHME ausgewiesen.
3. Umfang = PRD. Du fügst keine Module hinzu (kein Eigennutzer-Modus, kein Backend, keine Steuerrechnung), es sei denn, der Nutzer verlangt es ausdrücklich; dann hältst du PRD, Rechenregeln, Schema, Validator und Tests gleichzeitig synchron.
4. Keine Rechts-, Steuer- oder Anlageberatung im Rechtssinn: Die Leitplankentexte aus PRD §25 gehören wörtlich in die Oberfläche; Mietspiegel, Kappungsgrenze, Mietpreisbremse, GEG-Marker und Sanierungsansätze sind Nutzerannahmen und Prüfpunkte, nie Rechtsaussagen.
5. Technik: kein Backend, kein Tracking, keine externen Skripte oder Fonts, keine `eval`/`new Function`, importierte oder eingegebene Texte nur als `textContent` oder Formularwert. Der Basispreis eines Gewerks wird nie von der Automatik überschrieben; die angepasste Schätzung steht daneben.
6. Im Zweifel konservativ: Fehlt eine Eingabe, zeigt die App „nicht berechenbar" und nennt das fehlende Feld. Vorsorgliche Gewerke werden in der Verkäuferansicht als ⚠️-Bandbreite gezeigt, nie als belegte Zahl.

# Plattform-Kalibrierung (still für dich)
- Dateisystem und Ausführung vorhanden (Claude Code, Codex, Code Interpreter): Dateien direkt schreiben, danach `python scripts/validate_app.py ZIEL` und `node --check ZIEL/dist/app.js` ausführen und Fehler beheben, bevor du abgibst.
- Nur Chat ohne Dateisystem (claude.ai ohne Code-Ausführung, Gemini Gem, ChatGPT ohne Tools): Gib die drei Dateien vollständig und in fester Reihenfolge als Codeblöcke aus — `dist/index.html`, `dist/styles.css`, `dist/app.js` — jeweils in einem Stück, ohne Auslassungen wie „… Rest wie oben". Danach arbeitest du die manuelle Checkliste in `acceptance-tests.md` §1b ab und berichtest jeden Punkt. Bei Anpassungen bestehender Apps lieferst du ganze ersetzte Funktionen oder Blöcke, keine Diff-Fragmente.
- Kleines Kontextfenster: Lies die Referenzen in der Reihenfolge PRD → calculation-spec → ui-spec → data-schema; `data-tables.md` erst beim Schreiben von `app.js`, `acceptance-tests.md` in der Prüfphase.
- Webzugriff vorhanden: Bundesbank-Reihe und Grunderwerbsteuersätze dürfen gegen die Primärquellen geprüft werden. Kein Webzugriff: Datenstände aus `data-tables.md` beibehalten und nennen.
- Läuft der Skill in porthor, ist er dort nicht importierbar; dieser Skill ist für Claude, ChatGPT, Gemini und vergleichbare Werkzeuge gedacht.

# Arbeitsweise: Phasenmodell
Phase 1–3 sind Arbeit am Code, Phase 4 ist Prüfung; ausgegeben wird das Ergebnis nach dem Output-Vertrag.

## Phase 1 — Auftrag & Modus
Bestimme den Modus: (a) neue App: Zielverzeichnis, Standard `./mfh-kaufpreisrechner/`; (b) bestehende Variante anpassen: erst deren Struktur lesen, nur die verlangten Punkte ändern, fremde Dateien nicht überschreiben; (c) nur Konzept oder PRD: die einschlägigen Referenzinhalte liefern, keine App bauen. Übernimm genannte Hauswerte (Bundesland, Zins, Mieten, Objektkosten, Gewerkepreise) als Startwerte; enthält der Auftrag Klarnamen, bleiben sie außerhalb der ausgelieferten Defaults.

## Phase 2 — Referenzen lesen
Lies PRD, Rechenregeln, UI-Vertrag und Datenmodell vollständig. Notiere die Pflicht-IDs aus `ui-spec.md` §3, §5 und §7 als Arbeitsliste; jede fehlende ID lässt den Validator scheitern.

## Phase 3 — Bauen in fester Reihenfolge
1. `app.js`, Kopf: Konstanten aus `data-tables.md` (inkl. `buildingTrades`, `apartmentTrades`, `buildingPackages`, `apartmentPackages`, `evidenceLevels`), `globalDefaults`/`textDefaults` aus `data-schema.md`, `inspectionCatalog` mit der Dach-Frage `ceiling_alternative` (Namen wie in `ui-spec.md` §13).
2. Rechenkern als reine Funktionen: `getEffectiveTradePrice` (inkl. Dach-Sonderregel), `readRentUnits` (Deckel je Wohnung), `solvePrice`, `calculateScenario`, `computeMetrics`, `buildPriceBridge` — exakt nach `calculation-spec.md` inklusive §14 (ohne nicht anerkannte Positionen) und §15 (Korridor, Preisbrücke, Ampel, Übernahme-Delta).
3. `index.html`: Wizard mit vier Schritten Objekt → Miete → Kosten → Ergebnis (je Schritt Pflichtfelder, dann `details.advanced`, dann Weiter/Zurück), danach der Verkäuferdialog mit fünf Stationen und Zusammenfassungsblatt; jede ID genau einmal.
4. Rendering: Gewerkkarten (9 Gebäude, 7 Wohnungen), Mietliste je Wohnung und Hinweisliste werden von JS erzeugt; Wizard-Mechanik (`showStep`), Modusumschalter, „Mehr"-Menü.
5. `calculate()` schreibt in einem Durchlauf alle Käuferausgaben, Zusammenfassungszeilen, Ampel, Hinweise, die fünf Verkäuferstationen mit Preisbrücke (`buildPriceBridge`), das Zusammenfassungsblatt und `buildPurchaseOfferText`.
6. JSON-Export/-Import mit Whitelists und Ablehnung alter `kaufpreis-rechner`- sowie ETW-Dateien, Vollbild/Präsentationsmodus, Druck, Clipboard, `resetAll`.
7. `styles.css`: Design-DNA aus `ui-spec.md` §11, Präsentationsmodus, Druck A4 quer.
Sync-Pflicht: Jede Eingabe, die du anlegst, erscheint gleichzeitig in Käuferausgabe, Verkäuferansicht, Szenarien (soweit relevant), Indikation, JSON-Export und Importvalidierung.

## Phase 4 — Validieren
`validate_app.py` und `node --check` grün; danach die Abnahmegruppen S, O, W, H/R/L/F, K/Q, V, A/J und P aus `acceptance-tests.md` im Browser oder gedanklich am Code durchgehen. Startzustand mit Nordrhein-Westfalen muss 118.908 € Obergrenze, 329.015 € Gesamtinvestition und 2.139 € Monatsrate ergeben; die Preisbrücke muss rechnerisch schließen. Scheitert ein Test, korrigierst du den Code, nicht die Erwartung.

## Phase 5 — Abgabe
Nach dem Output-Vertrag.

# Rechenregeln (deterministisch)
Die Formeln stehen in `references/calculation-spec.md`; der Code muss diese Invarianten sichtbar erfüllen:
- Abzugsreihenfolge: erst die laufenden Objektkosten (nicht umlagefähige Betriebskosten + Verwaltung je m²), dann der prozentuale Puffer.
- Sanierung = Σ Menge × angepasste Schätzung je aktivem Gewerk plus Reserve; Dach-Sonderregel (oberste Geschossdecke 55 bzw. 20 €/m², Asbest × 1,25, Eindeckung × 1,10); „Verkäufer saniert vor Übergang" → 0 und Entlastung; „nicht anerkannt" nur in der Zweitrechnung 0.
- Korridor = Kaufpreis mit Puffer bis ohne Puffer; Preisbrücke Budget − Nebenkosten − Gebäude − Wohnungen = Korridor oben − Risikopuffer = Korridor unten, rechnerisch geschlossen; Ampel nur als Lage des Angebots zum Korridor.
- Mietdeckel-Kaskade je Wohnung: vermietet → min(Ziel, Mietspiegel, Ist × Kappung); leer → min(Ziel, Mietspiegel × ggf. 1,10); Ist-Basis ohne Deckel; Schnellrechnung verteilt die globalen Werte gleichmäßig.
- Kaufpreis: K = (Darlehen + EK − Investitionen + Inventar × GrESt-Satz) ÷ (1 + Nebenkostensatz), mit Fallunterscheidung K < Inventar.
- Prozentwerte werden als Prozent eingegeben, intern in voller Genauigkeit gerechnet, nur zur Anzeige gerundet; deutsches Zahlenformat; jeder Rechenschritt ist in der Oberfläche als Gleichung sichtbar.
- Kennzahlen mit den Rundungen aus Abschnitt 15 der Rechenregeln; fehlende Eingabe → „nicht berechenbar".

# Output-Vertrag
Gib nach der Umsetzung genau diese Punkte aus, knapp und ohne Marketing:
1. **Dateien** — Pfade der erzeugten oder geänderten Dateien.
2. **Funktionsumfang** — die Kernliste aus PRD §4 als abgehakte Liste; nicht umgesetzte Punkte offen benennen.
3. **Validierung** — Ausgabe von `validate_app.py` und `node --check` wörtlich, oder die Ergebnisse der manuellen Checkliste §1b.
4. **Datenstände und Annahmen** — Zinsreihe (Stand), Grunderwerbsteuer (Stand), Kostenbänder ⚠️; was der Nutzer vor einem realen Ankauf aktualisieren muss.
5. **Start** — wie die App geöffnet wird (`dist/index.html` lokal oder Sites-Workflow). Keine Zugangsdaten, Projekt-IDs oder temporären Archive nennen.

# Qualitäts-Gate (intern, vor Ausgabe)
- [ ] Vier Schritte Objekt → Miete → Kosten → Ergebnis mit Fortschritt, Zusammenfassungen und „Mehr"-Menü; Verkäuferdialog mit fünf Stationen und Zusammenfassungsblatt in dieser Reihenfolge?
- [ ] Schnellrechnung zeigt nur Pflichtfelder; Detailprüfung blendet alle `details.advanced` ein; alle Pflicht-IDs aus `ui-spec.md` vorhanden, keine doppelt?
- [ ] Startzustand: Gebäudepaket mittel 200.000 €, Wohnungspaket keine, Kaufpreis mit NRW 118.908 €, Preisbrücke geschlossen, Ampel bei 250.000 € „über dem Korridor", Dachalternative senkt auf 55 €/m²?
- [ ] JSON-Export → Import identisch; MFH-Datei wird mit Klartext abgelehnt; HTML in Notizen bleibt Text?
- [ ] Indikation enthält 14-Tage-Frist, § 311b BGB, Mietverträge/Kautionen (§ 566/566a), ggf. Erbengemeinschaft und den Abschlusssatz; Ein-Seiten-Druck mit höchstens sechs Zahlen?
- [ ] Ohne Bundesland sichtbare Warnung; Mietlisten-Flächenabgleich, Objektkosten- und Kesselalter-Hinweise greifen?
- [ ] Präsentationsmodus ohne native Fullscreen nutzbar (Ausknopf, Escape); keine Schemata, keine Regler außer Paket und Szenarien?
- [ ] Keine externen Abhängigkeiten, kein `innerHTML` mit Nutzertext, Disclaimer aus PRD §25 wörtlich?

# Datenschutz
Die App speichert nichts außerhalb des Browsers und exportiert nur auf Nutzerwunsch als JSON. In ausgelieferten Startwerten stehen keine echten Personen-, Adress- oder Objektdaten; Beispielwerte sind neutral (Beispielstraße, Frau Beispiel). Nennt der Auftrag Klarnamen, Adressen oder Kontodaten, behandle sie vertraulich, fordere keine weiteren personenbezogenen Daten an und schreibe sie nicht in Code oder Defaults — sie gehören in eine JSON-Datei des Nutzers. Enthält ein bereitgestellter Datensatz Platzhalter (z. B. Name_Gruppe1), verwende sie wörtlich weiter und rekonstruiere niemals Klardaten.

# Reihenfolge am Ende der Ausgabe
Halte diese Reihenfolge immer ein: (1) das Ergebnis nach dem Output-Vertrag, (2) die Anschluss-Aufgaben als kurzes Angebot, (3) eine Trennlinie „---", (4) als letzte Zeile der Freigabe-Hinweis. Erzeugt der Skill einen versandfertigen Text, endet der kopierfertige Teil vor Schritt 2.

# Startverhalten
Liegt ein Auftrag vor, beginne direkt mit Phase 1 — kein Interview. Fehlen Angaben, baue die neue App im vollen Referenzumfang in `./etw-kaufpreisrechner/` und nenne am Ende, welche Startwerte der Nutzer anpassen kann. Nur der Modus „bestehende Variante anpassen" ohne mitgelieferten Code ist ein Grund für eine Rückfrage. Ergebnisse aus dem porthor-Skill „Due Diligence: Mehrfamilienhaus" (Mieterliste, Objektkosten, Sanierungsstau) nimmst du als Startwerte an — nur belegte Zahlen, keine Bewertungen.

# Mögliche Anschluss-Aufgaben
Biete dem Nutzer nach dem Ergebnis an:
- Die größten Risiken bzw. offenen Punkte der Kalkulation zusammenzufassen
- Fehlende Unterlagen oder Angaben aufzulisten, die vor einem realen Ankauf zu beschaffen sind
- Hauswerte einzutragen (Bundesland, Zins, Objektkosten, Puffer, Gewerkepreise) und als neue Startwerte zu setzen
- Zins- und Steuerstand aus den Primärquellen zu aktualisieren
- Farben, Logo oder Objektbezeichnung anzupassen; eine zweite Sprachfassung anzulegen
- Aus der Kaufpreisindikation ein formgerechtes Kundenanschreiben (DIN 5008) oder eine versandfertige E-Mail zu formulieren
Folge-Skills: „Due Diligence: Mehrfamilienhaus" (Unterlagenprüfung vor dem Kauf), „Finanzierungs-Analyse" (Bankfähigkeit), „Kaufvertragsentwurf-Check" (vor dem Notartermin).

# Anpassungszonen
| Bereich | Anpassung durch Nutzer | Risiko |
|---|---|---|
| `globalDefaults`, `textDefaults` | Startwerte des eigenen Objekts | gering |
| `buildingTrades`, `apartmentTrades`, `buildingPackages`, `apartmentPackages` | Hauspreise, Mengen, Pakete | gering |
| `:root`-Farben, Texte der Oberfläche, Objektbezeichnung | Erscheinungsbild | gering |
| `buildPurchaseOfferText` | Tonfall und Zusatzvorbehalte (Vorbehalte nie streichen) | mittel |
| `transferTaxRates`, `interestTimeline` | nur mit Primärquelle und neuem Datenstand | mittel |
| Rechenkern, Rundungen, JSON-Sicherheitsregeln, Rechtshinweise | **nicht verändern** | hoch |

# Rechtsrahmen
Alle Ergebnisse der App sind Orientierung, keine Bewertung, Finanzierungszusage, Rechts-, Steuer- oder Anlageberatung. Aussagen zu Milieuschutz-/Erhaltungssatzung, Mietverhältnissen, Grundbuchlasten und Baurecht sind Hinweise auf Prüfbedarf, keine Rechtsauskunft. Die Kaufpreisindikation ist unverbindlich; eine Frist beschreibt die Gültigkeit der Gesprächsgrundlage und begründet keine Bindung — ein Grundstückskauf entsteht erst durch notarielle Beurkundung (§ 311b BGB). Verwende in App und Ausgabe nie Formulierungen wie „garantiert", „rechtssicher", „vollkommen sicher" oder „geprüft konform".

# Status & Freigabe
Dein Ergebnis ist ein Entwurf zur Entscheidungsvorbereitung — keine Rechts-, Steuer- oder Anlageberatung. Eine fachlich verantwortliche Person prüft Zahlen, Fristen und rechtliche Aussagen und gibt das Ergebnis frei, bevor es verwendet, versendet oder veröffentlicht wird. Schließe jede finale Ausgabe mit einer kurzen Zeile ab, die darauf hinweist (eine Zeile genügt, z. B. „Entwurf — bitte fachlich prüfen und freigeben.").

# Anhang — Konservative Default-Annahmen (nur ohne bessere Daten; immer ⚠️ markieren)
- Grunderwerbsteuer: 16 Ländersätze, Stand September 2026 (`data-tables.md` §1). Bauzinsen: Bundesbank-Reihe bis Juli 2026, Stand 02.09.2026; Aug–Nov 2026 lineare Annahme bis 5,8 %.
- Gewerk-Referenzwerte ⚠️ (Dach 280 €/m², Fassade 197 €/m², Fenster 1.080 €/Stk., Kellerdecke 50 €/m², Wärmepumpe 52.790 €, Bäder 30.000 €, Oberflächen 100 €/m², Elektro 130 €/m²): Orientierung für die Verhandlung, ersetzen keine Angebote.
- Objektkosten ⚠️ 1,00 €/m² nicht umlagefähig + 0,50 €/m² Verwaltung; Sicherheits-/Mietpuffer 15 %; Kaufnebenkosten 2 % Notar/Grundbuch ohne Makler.

## Referenzdateien

Der Skill verweist auf diese Dateien. In der Ordner-Fassung liegen sie unter
`references/`, `scripts/` und `agents/`; hier stehen sie vollständig, in derselben
Reihenfolge, jeweils mit ihrem Pfad als Überschrift.

### agents/openai.yaml

`````
interface:
  display_name: "MFH-Kaufpreisrechner bauen"
  short_description: "Programmiert einen geführten Kaufpreis-Rechner für Mehrfamilienhäuser."
  default_prompt: "Nutze $build-mfh-kaufpreisrechner, um die vollständige MFH-Kaufpreisrechner-App gemäß den Referenzen zu programmieren und zu validieren."
`````

### references/acceptance-tests.md

`````
# Abnahme- und Regressionstests (MFH 2.0)

## 1. Automatisch

`python3 SKILL_ROOT/scripts/validate_app.py APP_ROOT` und `node --check APP_ROOT/dist/app.js`, beide Exit 0.

## 1b. Manuelle Checkliste (ohne Python/Node)

1. Drei Dateien, nur lokale Verweise. 2. Reihenfolge `stepObject` < `stepRent` < `stepCosts` < `stepResult`; Dialog `seller-start` < `seller-burdens` < `seller-uncertainty` < `seller-corridor` < `seller-options` < `sellerSummarySheet`. 3. Alle IDs aus `ui-spec.md` §5/§7 genau einmal. 4. `app.js` enthält `jsonSchema = "mfh-kaufpreisrechner"`, `jsonVersion = 1`, `buildingPackages`, `apartmentPackages`, `evidenceLevels`, `getEffectiveTradePrice(`, `readRentUnits(`, `solvePrice(`, `buildPriceBridge(`, `buildPurchaseOfferText(`, `showStep(`, `renderInterestChart("buyerInterestChart"`, `§ 311b BGB`, `GEG-Risiko`, `oberste Geschossdecke`, `MFH-Kaufpreisrechner`. 5. Kein `eval`, kein `innerHTML` mit Nutzertext. 6. CSS: `[hidden]`, `presentation-mode`, `100dvh`, `@media print`, `print-seller`. 7. Startwerte: Gebäude mittel 200.000 €, Wohnungen keine; NRW → 118.908 € / 329.015 € / 2.139 €. 8. JSON roundtrip; Konsole fehlerfrei; keine horizontale Scrollbar bei 390 px. 9. Verkäuferansicht aus Schritt 4; Druck nur Zusammenfassungsblatt.

## 2. Start und Wizard (S)

| ID | Aktion | Erwartung |
|---|---|---|
| S-01 | Öffnen | Schritt 1, „Schritt 1 von 4", Schnellrechnung ohne Detailbereiche. |
| S-02 | Startwerte | 296 m², 6 WE, 1935, Miete 10/10, Objektkosten 1,00/0,50, Gebäude mittel, Wohnungen keine, Zins 4,8, Tilgung 3. |
| S-03 | Weiter ohne Bundesland | Schritt 2, Zusammenfassung „296 m² · 6 WE · 1935 · Bundesland offen". |
| S-04 | Bis Schritt 4, NRW | 118.908 € / 329.015 € / 2.139 €; Rechenweg drei Zeilen. |
| S-05 | Detailprüfung an/aus | Erweiterte Bereiche erscheinen/verschwinden, Werte bleiben. |
| S-06 | Schritt-Leiste | Schritt 3 erst nach „Weiter" aus Schritt 2. |
| S-07 | „Mehr"-Menü | Export, Import, Drucken, Vollbild, Startwerte; Escape schließt. |
| S-08 | Wohnfläche leer | „Weiter" blockiert mit Hinweis. |

## 3. Objekt (O)

| ID | Aktion | Erwartung |
|---|---|---|
| O-01 | Bayern / Bremen / NRW | 3,5 / 5,5 / 6,5 %. |
| O-02 | Wohneinheiten 8 | Mietliste (Detail) hat acht Zeilen à 37 m². |
| O-03 | Detail: Flächenquelle Exposé | Hinweis am Feld. |
| O-04 | Detail: Gas-Zentral, Wärmeerzeuger 1990 | Hinweis „36 Jahre, GEG § 72 prüfen". |
| O-05 | Detail: Verkäufertyp Erbengemeinschaft | Indikation nennt Zustimmung aller Erben. |
| O-06 | Detail: Kontakt Frau Erika Beispiel | Indikation persönlich. |

## 4. Miete (W)

| ID | Aktion | Erwartung |
|---|---|---|
| W-01 | Erwartet 12, Ist 10, Cap 10 (Detail) | 2.960 € (Mietspiegel 10 bindet je Wohnung). |
| W-02 | Cap 0, erwartet 12 | Kappung 12,00 bindet → 3.552 €. |
| W-03 | angespannt ja, Cap 0 | 11,50 → 3.404 €. |
| W-04 | WE 1 leer, angespannt, Cap 10, Ziel 12 | WE 1 mit 11,00 (Bremse), übrige 10,00. |
| W-05 | Basis Ist | 2.960 € ohne Deckel. |
| W-06 | WE 2 Fläche 60 (Summe ≠ 296) | `rentRollAreaStatus` warnt; Rechnung läuft. |
| W-07 | „Aus Stammdaten neu verteilen" | sechs Zeilen à 49,33 m², Werte global. |
| W-08 | Garagen 200, Stellplätze 100, Gewerbe 500, Sonstige 50 | +850 €. |

## 5. Kosten (H, R, F)

| ID | Aktion | Erwartung |
|---|---|---|
| H-01 | 1,00 + 0,50 bei 296 m² | 444 €; Hinweis nur außerhalb 1,00–3,00 €/m². |
| H-02 | Kosten > Miete | Rate 0, kein negatives Darlehen. |
| R-01 | Gebäudepaket gering | 49.800 €. |
| R-02 | Gebäudepaket umfassend | 252.720 €. |
| R-03 | Wohnungspaket Oberflächen / voll | 29.600 € / 248.080 €. |
| R-04 | Detail: Stränge aktivieren bei mittel | 220.000 €, Label „individuell". |
| R-05 | Detail: Dach, Geschossdecke „Möglich", kein Asbest, Eindeckung intakt | angepasst 55 €/m², Position 8.800 €; Entlastung in Station 3. |
| R-06 | Detail: Dach zusätzlich GEG | 60,50 → gerundet 60 €/m². |
| R-07 | Detail: Dach Geschossdecke „bereits gedämmt" | ≤ 20 €/m². |
| R-08 | Detail: Fassade drei positive Antworten | 197 × 0,64 = 126,08 → 126 €/m². |
| R-09 | Detail: Gebäudereserve 10 % | 10 % der aktiven Gebäudekosten. |
| R-10 | Detail: „Mengen aus Objekt übernehmen" bei 296/6 | Flächen 296, Bäder/Küchen 6, Türen 30. |
| R-11 | Detail: Dach „Verkäufer saniert vor Übergang" | Gebäude 155.200 €; Station 5 Übernahme-Delta +41.290 €; Station 1 Entlastung. |
| R-12 | Detail: Wärmepumpe „nicht anerkannt" | Hauptrechnung unverändert; Station 3 zeigt Rechnung ohne Wärmepumpe. |
| R-13 | Detail: Evidenz Fassade „vorsorglich" | Station 2 mit ⚠️ und Bandbreite ±20 %. |
| F-01 | Käuferprovision 3,57 % | Nebenkosten 12,07 % (NRW). |
| F-02 | Detail: Provisionsrechner 6/19/50 → übernehmen | 3,57 %. |
| F-03 | Detail: Inventar 10.000 | Kaufpreis steigt um ca. 599 €. |
| F-04 | EK 50.000 | Kaufpreis +46.083 €. |
| F-05 | Zins + Tilgung 0 | Fehler. |

## 6. Ergebnis (K, Q)

| ID | Aktion | Erwartung |
|---|---|---|
| K-01 | Startwerte NRW | 118.908 € / 329.015 € / 2.139 €; `stepDeductions` „− 10.107 − 200.000 − 0". |
| K-02 | Szenarien | optimistisch > realistisch > konservativ. |
| K-03 | Detail: Korridor | 118.908 € bis 172.421 €; Risikopuffer 53.513 €. |
| Q-01 | Kennzahlen | 29,87 %, Faktor 3,3, 402 €/m², Überschuss 377 €; Full: 19.818 €/WE. |
| Q-02 | Angebot 250.000 | „über dem Korridor", +131.092 €. |
| Q-03 | Angebot 150.000 | „nur ohne Risikopuffer tragfähig". |
| Q-04 | Angebot 0 | „nicht berücksichtigt". |
| Q-05 | Detail: Punkt „Nov 26" per Enter | Zins 5,8 %. |
| Q-06 | Aktionen | fokussiert / öffnet Dialog / exportiert. |

## 7. Verkäuferansicht (V), Indikation (A), JSON (J), Responsive (P)

| ID | Aktion | Erwartung |
|---|---|---|
| V-01 | Öffnen ohne Angebot | Ausgangspunkt Korridor 118.908–172.421 €. |
| V-02 | Preisbrücke | 387.077 − 14.656 − 200.000 − 0 = 172.421 → − 53.513 = 118.908 (geschlossen). |
| V-03 | Belastungsliste | fünf Gebäudegewerke mit Evidenz, Beleg, Basis → angepasst; Summe 200.000 €. |
| V-04 | Station 3 | vorsorgliche Position als Bandbreite; nicht anerkannt → Zweitrechnung; Entlastung Geschossdecke. |
| V-05 | Station 4/5 | Schlüsselsatz 329.015 € / 118.908 €; drei Optionen; Übernahme-Delta je Gewerk. |
| V-06 | Drucken | nur Zusammenfassungsblatt, ≤ 6 Zahlen. |
| A-01 | Indikation | 14 Tage, § 311b BGB, § 566/566a, Abschlusssatz; bei Erbengemeinschaft Zustimmungsvorbehalt. |
| J-01 | Export/Import | Schema v1, 9 + 7 Gewerke, Mietliste, Pakete; identisch. |
| J-02 | alte `kaufpreis-rechner`-Datei / ETW-Datei | Ablehnung mit Klartext. |
| J-03 | HTML im Beleg | Text. |
| P-01 | 390 px | keine Seitenscrollbar; Mietliste scrollt lokal. |
| P-02 | Vollbild ohne native API | Präsentationsmodus, Ausknopf, Escape. |

## 8. Freigabeblocker

Falsche Kaufpreisformel · falscher Steuersatz · Preisbrücke nicht geschlossen · Dach-Sonderregel falsch · Deckel je Wohnung falsch · Datenverlust im JSON · Verkäuferansicht ohne fünf Stationen · bindend klingende Indikation · unbenutzbare iPad-Ansicht · JS-Syntaxfehler.
`````

### references/calculation-spec.md

`````
# Berechnungsspezifikation MFH-Kaufpreisrechner (Version 2.0)

Version 2.0 (09.09.2026): Rechenkern des Vollrechners 5.0 unverändert (Rückwärtsrechnung, Checkpreis-Regel, Dach-Sonderregel); neu sind Sanierungspakete (§7.2), Evidenzstufe und „nicht anerkannt" je Gewerk (§7.3, §14), Mietdeckel je Wohnung (§2), Inventar-Regel (§6), Korridor, Preisbrücke, Übernahme-Delta, Ampel und Überschuss (§15).

Alle Geldbeträge intern mit voller Genauigkeit, Anzeige gerundet (Euro ganz, Kennzahlen nach §13). Prozentsätze als Prozentwerte (`4.8`). Deutsches Zahlenformat. Fehlende Eingabe → „nicht berechenbar" mit Feldnennung.

## 1. Notation

| Symbol | Bedeutung |
|---|---|
| `A` | vermietbare Fläche in m² |
| `N` | Wohneinheiten |
| `a_i`, `r_i`, `z_i`, `c_i` | Fläche, Ist-Miete, Zielmiete, Mietspiegelwert der Wohnung `i` |
| `E` | weitere monatliche Mieten (Garagen + Stellplätze + Gewerbe + Sonstige) |
| `B`, `V` | nicht umlagefähige Betriebskosten, Verwaltungskosten in €/m²/Monat |
| `p` | Sicherheits-/Mietpuffer in % |
| `i`, `t` | Sollzins, anfängliche Tilgung in % p. a. |
| `R_G`, `R_W` | Gebäude- und Wohnungssanierung inkl. Reserve |
| `R` | `R_G + R_W` |
| `EK` | Eigenkapital |
| `g`, `n_o` | Grunderwerbsteuersatz; Notar + Käuferprovision + weitere Nebenkosten in % |
| `INV` | mitverkauftes bewegliches Inventar in € |
| `K` | maximaler Kaufpreis |

## 2. Wohnraummiete

**Schnellrechnung** (Mietliste nicht bearbeitet): alle Wohnungen tragen `a_i = A ÷ N` (Rest in der letzten Zeile), `r_i` = globale Ist-Miete, `z_i` = globale Zielmiete, `c_i` = globaler Mietspiegelwert, Status „vermietet". **Detailprüfung:** Mietliste je Wohnung mit eigenen Werten und Status vermietet/leer; die Summe der `a_i` wird mit `A` verglichen (Abweichung > 0,05 m² → Hinweis).

Mietbasis `current`: `Wohnraummiete = Σ a_i × r_i`.

Mietbasis `target` mit Deckel-Kaskade je Wohnung (alle Grenzen Nutzerannahmen):

```text
Grenze_Mietspiegel_i = c_i > 0 ? c_i : ∞
vermietet: Kappung% = angespannt ? 15 : 20
           Grenze_Kappung_i = r_i > 0 ? r_i × (1 + Kappung%/100) : ∞
           z_w_i = min(z_i, Grenze_Mietspiegel_i, Grenze_Kappung_i)
leer:      Grenze_Bremse_i = angespannt ? Grenze_Mietspiegel_i × 1,10 : Grenze_Mietspiegel_i
           z_w_i = min(z_i, Grenze_Bremse_i)
Wohnraummiete = Σ a_i × z_w_i
```

Die App nennt je Wohnung die bindende Grenze und die Summe der Deckelung („Zielmieten um 120 € pro Monat gedeckelt").

## 3. Weitere Mieten und Kaltmiete

```text
E = Garagen + Stellplätze + Gewerbe + Sonstige
Kaltmiete = Wohnraummiete + E
```

## 4. Laufende Abzüge und verfügbare Rate

```text
laufende_Objektkosten = A × (B + V)
vor_Puffer = max(0, Kaltmiete − laufende_Objektkosten)
Pufferbetrag = vor_Puffer × p / 100
verfügbare_Rate = max(0, vor_Puffer − Pufferbetrag)
```

## 5. Käuferprovision

`brokerBuyerRate` ist der rechnende Wert. Provisionsrechner: `m_netto × (1 + USt/100) × Käuferanteil/100`, schreibt den Wert nur auf Nutzeraktion. Beim Mehrfamilienhaus gilt § 656c BGB nicht; kein Hinweis.

## 6. Kaufnebenkosten mit Inventar-Regel

```text
n_o = Notar_Grundbuch_% + Käuferprovision_% + Weitere_%
GrESt_Basis = max(0, K − INV)
Grunderwerbsteuer_€ = GrESt_Basis × g / 100
Kaufnebenkosten_€ = Grunderwerbsteuer_€ + K × n_o / 100
```

## 7. Sanierung

### 7.1 Positionen

Je aktives Gewerk `j`: `Positionskosten_j = Menge_j × angepasste_Schätzung_j`. Gebäude: `direkt_G`, `Reserve_G = direkt_G × Gebäudereserve_% / 100`, `R_G = direkt_G + Reserve_G`. Wohnungen analog `R_W`. `R = R_G + R_W`. Gewerke mit `payer = "seller"` (Verkäufer saniert vor Übergang) zählen 0 und erscheinen als Entlastung.

### 7.2 Pakete (Schnellrechnung)

Regler `buildingPackage` (0 gering / 1 mittel / 2 umfassend) und `apartmentPackage` (0 keine / 1 Oberflächen / 2 Bäder, Oberflächen, Elektro) aktivieren die Gewerke aus `data-tables.md` §5 mit Standardmengen und -preisen: Gebäude 49.800 / 200.000 / 252.720 €, Wohnungen 0 / 29.600 / 248.080 € (bei 296 m², 6 WE). Standard mittel + keine = 200.000 €. Manuelle Änderung in der Detailprüfung → Paket „individuell". Flächen- und Einheitenmengen folgen `A` und `N`, solange sie nicht manuell gesetzt wurden.

### 7.3 Angepasste Schätzung (optionale Zustandsschätzung)

```text
Faktor_roh = 1 − 0,12 × positive + 0,08 × negative     (nur beantwortete Fragen)
Faktor = min(1,30, max(0,55, Faktor_roh))
angepasst = Basispreis × Faktor
GEG-Risiko markiert: × 1,10
Rundung auf die Schrittweite des Basispreisreglers
```

**Sonderregel Dach:** oberste Geschossdecke „möglich" → `angepasst_vor_Risiken = min(Basispreis, 55 €/m²)`; „bereits gedämmt" → `min(Basispreis, 20 €/m²)`; danach kumulativ Asbestverdacht × 1,25, Eindeckung schadhaft × 1,10, GEG × 1,10. Ohne Geschossdecken-Antwort gilt die allgemeine Regel, Dachzuschläge zusätzlich. Der Basispreis bleibt im Feld; die UI nennt das Ergebnis „angepasste Schätzung" und zeigt „Basis → angepasst · Begründung".

Evidenzstufe je Gewerk (`documented` Angebot liegt vor, `likely` Schätzung nach Besichtigung, `precautionary` vorsorglich) ändert die Rechnung nicht; sie steuert die Kennzeichnung in Verkäuferansicht und Preisbrücke (vorsorgliche Positionen mit ⚠️ und Bandbreite ±20 % des Betrags).

## 8. Annuität, Darlehen, Kaufpreis

```text
Annuität = (i + t) / 100
maximales_Darlehen = verfügbare_Rate × 12 ÷ Annuität
Budget = maximales_Darlehen + EK − R
K = (Budget + INV × g/100) ÷ (1 + (g + n_o)/100)          Fall K < INV (nur bei INV > 0): K = Budget ÷ (1 + n_o/100)
K = max(0, K)
```

Zins + Tilgung = 0 → Fehler. Ohne Puffer: dieselbe Rechnung mit `vor_Puffer`.

## 9. Gesamtinvestition und Bankfinanzierung

```text
Gesamtinvestition = K + Kaufnebenkosten_€ + R
Bankfinanzierung = max(0, Gesamtinvestition − EK)
anfängliche_Monatsrate = Bankfinanzierung × Annuität ÷ 12
Ratenanteil_% = anfängliche_Monatsrate ÷ Kaltmiete × 100
```

## 10. Drei Szenarien

Optimistisch: Miete +5 %, Zins −0,5 Pp., Sanierung −10 %, Objektkosten −0 %. Konservativ: Miete −5 %, Zins +1,0 Pp., Sanierung +15 %, Objektkosten +10 %. Acht Regler in der Detailprüfung; Käufer- und Verkäuferansicht zeigen dieselben Werte; Korridor, Verkäuferansicht und Indikation nutzen immer „realistisch".

## 11. Zinsdiagramm

`Monatsrate_Datenpunkt = D × ((z + t)/100) ÷ 12`; Käuferdiagramm `D` = Vergleichsdarlehen (250.000 €).

## 12. Angebotspreis und Vergleichswert

`Delta_Angebot = Angebot − Korridor_unten` (nur bei Angebot > 0); `Vergleichswert_€ = €/m² × A`.

## 13. Kennzahlen

| Kennzahl | Formel | Rundung |
|---|---|---|
| Kaufpreis je m² | `Preis ÷ A` | ganze € |
| Kaufpreis je Wohneinheit | `Preis ÷ N` | ganze € |
| Bruttorendite | `Kaltmiete × 12 ÷ Preis × 100` | 2 NK, % |
| Kaufpreisfaktor | `Preis ÷ (Kaltmiete × 12)` | 1 NK |
| Nettomietrendite | `(Kaltmiete × 12 − laufende_Objektkosten × 12) ÷ (Preis + Kaufnebenkosten_€) × 100` | 2 NK, % |
| Objektkosten je m² | `B + V` | 2 NK |
| Sanierung je m² / je WE | `R ÷ A`, `R ÷ N` | ganze € |
| Monatlicher Überschuss | `Kaltmiete − laufende_Objektkosten − anfängliche_Monatsrate` | ganze € |
| AfA-Satz (Orientierung) | `afaRates` nach Baujahr | — |

## 14. Rechnung ohne nicht anerkannte Positionen

Jedes Gewerk trägt `disputed`. `K_ohne_Bestritten` setzt alle bestrittenen Gewerke auf 0 (§7, §8 erneut). Verkäuferansicht zeigt beide Werte; Indikation nutzt die Hauptrechnung.

## 15. Korridor, Preisbrücke, Übernahme-Delta, Ampel, Überschuss

```text
Korridor_unten = K (mit Puffer) · Korridor_oben = K_ohne_Puffer · Risikopuffer_€ = oben − unten
Preisbrücke: Budget_ohne_Puffer = vor_Puffer × 12 ÷ Annuität + EK
             − Kaufnebenkosten bei Korridor_oben − R_G − R_W = Korridor_oben − Risikopuffer_€ = Korridor_unten
Ampel: Angebot ≤ unten → „im tragfähigen Bereich"; ≤ oben → „nur ohne Risikopuffer tragfähig"; > oben → „über dem Korridor"
Übernahme-Delta je Gewerk j (payer ≠ seller): ΔK_j = Positionskosten_j × (1 + Reserve_%/100) ÷ (1 + (g + n_o)/100)
Überschuss = Kaltmiete − laufende_Objektkosten − anfängliche_Monatsrate
```

## 16. Warnschwellen und automatische Hinweise

| Prüfung | Bedingung | Wirkung |
|---|---|---|
| Bundesland fehlt | leer | Warnung „Obergrenze zu hoch" |
| Zins + Tilgung = 0 | — | Fehler |
| Budget < 0 | — | Warnung |
| Mietlistenfläche ≠ A | Abweichung > 0,05 m² | Hinweis am Feld |
| Objektkosten je m² | außerhalb 1,00–3,00 €/m² | Hinweis (⚠️ Orientierung) |
| Wärmeerzeuger | Alter > 30 J. bei Gas-/Öl-Zentralheizung, `heatingYear` > 0 | negativer Hinweis „GEG § 72 prüfen" |
| Notar/Grundbuch | Korridor_unten < 100.000 | Hinweis GNotKG |
| Flächenquelle | Exposé/offen | Hinweis am Feld |
| Denkmalschutz/Brandschutz/Statik | Antworten im Gewerk „Sonstige Gebäudearbeiten" | negative Hinweise |

## 17. Rechenbeispiel (Startwerte)

NRW 6,5 % + Notar 2 %; 296 m² × 10 €/m² = 2.960 €; Objektkosten 296 × 1,50 = 444 €; vor Puffer 2.516 €; Rate 2.138,60 €; Annuität 7,8 % → Darlehen 329.015,38 €; R = 200.000 €; Budget 129.015,38 €; **K = 118.908,19 €**; Nebenkosten 10.107,20 €; Gesamtinvestition 329.015,38 €; Monatsrate 2.138,60 €. Ohne Puffer: Budget 387.076,92 €, Korridor oben 172.421,13 €, Nebenkosten 14.655,80 €; Preisbrücke 387.076,92 − 14.655,80 − 200.000 − 0 = 172.421,13 → − 53.512,94 = 118.908,19 (geschlossen). Kennzahlen: 402 €/m², 19.818 €/WE, Bruttorendite 29,87 %, Faktor 3,3, Nettomietrendite 23,40 %, Überschuss 377 €. Angebot 250.000 € → „über dem Korridor", Delta +131.092 €. Übernahme Dach (44.800 €) durch Verkäufer: +41.290 €. Die Oberfläche zeigt **118.908 €**.
`````

### references/data-schema.md

`````
# JSON-Datenmodell (MFH, Version 1)

## 1. Format

`.json`, UTF-8; Schema `mfh-kaufpreisrechner`, Version `1`; Import ≤ 1 MB; abgeleitete Werte werden neu berechnet. Ablehnungen mit Klartext: `schema: "kaufpreis-rechner"` (Vollrechner 5.0, Versionen 1–5) → „Diese Datei stammt aus dem alten MFH-Kaufpreisrechner (Version 5) und wird nicht mehr gelesen; bitte in Version 2.0 neu erfassen."; `schema: "etw-kaufpreisrechner"` → „Diese Datei stammt aus dem ETW-Kaufpreisrechner und passt nicht zum MFH-Kaufpreisrechner."

## 2. Top-Level

```json
{ "schema": "mfh-kaufpreisrechner", "version": 1, "exportedAt": "…", "inputs": {}, "rentUnits": [], "renovation": { "buildingPackage": "medium", "apartmentPackage": "none", "building": [], "apartments": [] } }
```

## 3. `inputs` — Text/Auswahl

| Schlüssel | Werte | Standard |
|---|---|---|
| `mode` | `quick`, `detail` | `quick` |
| `scenarioSelected` | `realistic`, `optimistic`, `conservative` | `realistic` |
| `objectName` | ≤ 120 | `Mehrfamilienhaus` |
| `street` / `postalCode` / `city` | 160 / 12 / 100 | leer |
| `federalState` | leer oder 16 Länder | leer |
| `areaSource` | `unknown`, `survey`, `expose`, `plans` | `unknown` |
| `heatingType` | `unknown`, `gas-central`, `oil-central`, `district`, `heatpump`, `gas-unit`, `electric` | `unknown` |
| `energyCertificateStatus` / `energyCertificateType` / `energyClass` / `energyCertificateValidUntil` | wie ETW | `unknown` / `unknown` / `unknown` / leer |
| `sellerType` | `unknown`, `private`, `company`, `estate` | `unknown` |
| `sellerSalutation` … `sellerEmail` | wie ETW | leer |
| `tightMarket` | `no`, `yes` | `no` |
| `rentBasis` | `current`, `target` | `target` |
| `otherRentLabel` | ≤ 100 | `Sonstige Mieteinnahmen` |
| `rentUnitsCustomized` | `no`, `yes` (Liste manuell bearbeitet) | `no` |

## 4. `inputs` — Zahlen

| Schlüssel | Einheit | Standard |
|---|---|---:|
| `area` / `units` | m² / Anzahl | 296 / 6 |
| `plotArea` / `floors` / `yearBuilt` / `heatingYear` | m² / Anzahl / Jahr / Jahr (0 = unbekannt) | 0 / 3 / 1935 / 0 |
| `currentRentPerSqm` / `rentPerSqm` / `rentCapPerSqm` | €/m²/Monat | 10 / 10 / 10 |
| `garageRent` / `parkingRent` / `commercialRent` / `otherRent` | €/Monat | 0 |
| `nonRecoverableCostPerSqm` / `managementCostPerSqm` | €/m²/Monat | 1 / 0.5 |
| `rentBuffer` | % | 15 |
| `buildingReserve` / `apartmentReserve` | % | 0 / 0 |
| `optimisticRentIncrease` / `optimisticInterestReduction` / `optimisticRenovationReduction` / `optimisticCostReduction` | % / Pp. / % / % | 5 / 0.5 / 10 / 0 |
| `conservativeRentReduction` / `conservativeInterestIncrease` / `conservativeRenovationIncrease` / `conservativeCostIncrease` | % / Pp. / % / % | 5 / 1 / 15 / 10 |
| `interest` / `repayment` | % p. a. | 4.8 / 3 |
| `chartLoan` | € | 250000 |
| `notaryLandRegistryRate` | % | 2 |
| `brokerBuyerRate` / `brokerCommissionNetRate` / `brokerVatRate` / `brokerBuyerShareRate` | % | 0 / 0 / 19 / 50 |
| `otherPurchaseCostsRate` | % | 0 |
| `equity` / `inventoryValue` / `askingPrice` / `marketPricePerSqm` | € / € / € / €/m² | 0 |
| `offerValidityDays` | Tage 1–60 | 14 |

## 5. `rentUnits[]`

Genau `units` Einträge (nach Rundung): `{ "label": "WE 1", "area": 49.33, "currentRent": 10, "targetRent": 10, "capRent": 10, "tenancy": "rented" }` — `label` ≤ 50, Zahlen ≥ 0, `tenancy` ∈ `rented`, `vacant`. Bei `rentUnitsCustomized = no` wird die Liste beim Import aus den globalen Werten neu erzeugt.

## 6. `renovation`

`buildingPackage` ∈ `low`, `medium`, `high`, `custom`; `apartmentPackage` ∈ `none`, `surfaces`, `full`, `custom`. `building[]` genau die 9 IDs `roof, facade, windows, cellar, heatpump, heating-distribution, plumbing, main-electric, other-building`; `apartments[]` genau die 7 IDs `surface-heating, bathrooms, apartment-electric, surfaces, interior-doors, kitchens, other-apartment`. Je Gewerk:

```json
{ "id": "roof", "active": true, "quantity": 160, "unitPrice": 280, "inspection": { "insulated": "unknown", "ceiling_alternative": "possible", "asbestos": "no", "covering": "yes" }, "gegRisk": false, "evidence": "likely", "source": "", "disputed": false, "sellerRenovates": false }
```

`evidence` ∈ `documented`, `likely`, `precautionary`; `inspection`-Antworten `unknown`/`yes`/`no`, Dach `ceiling_alternative` zusätzlich `possible`; fehlende Fragen = `unknown`; `source` ≤ 200.

## 7. Import-Sicherheitsregeln

Wie ETW 2.0 §8: nur `JSON.parse`, Whitelists, Längen, kein Teilzustand, fehlende `inputs`-Schlüssel = Fehler, doppelte oder fehlende Gewerke = Fehler, `rentUnits`-Länge ≠ `units` = Fehler.
`````

### references/data-tables.md

`````
# Datentabellen (kopierfertig für `dist/app.js`)

Jede Tabelle trägt einen Datenstand. Übernimm die Konstanten wörtlich. Aktualisiere sie nur aus der in `sources.md` genannten Primärquelle und ändere dann gleichzeitig den sichtbaren Standhinweis in `dist/index.html`, dieses Dokument und das Rechenbeispiel in `calculation-spec.md`.

## 1. Grunderwerbsteuer nach Bundesland (Stand September 2026)

```js
const transferTaxRates = {
  "Baden-Württemberg": 5.0,
  Bayern: 3.5,
  Berlin: 6.0,
  Brandenburg: 6.5,
  Bremen: 5.5,
  Hamburg: 5.5,
  Hessen: 6.0,
  "Mecklenburg-Vorpommern": 6.0,
  Niedersachsen: 5.0,
  "Nordrhein-Westfalen": 6.5,
  "Rheinland-Pfalz": 5.0,
  Saarland: 6.5,
  Sachsen: 5.5,
  "Sachsen-Anhalt": 5.0,
  "Schleswig-Holstein": 6.5,
  Thüringen: 5.0,
};
```

Kein Bundesland gewählt → rechnerisch 0 % und sichtbare Warnung.

## 2. Bauzinsreihe für das Zinsdiagramm (Deutsche Bundesbank, Stand 02.09.2026)

Wohnungsbaukredite an private Haushalte, anfängliche Zinsbindung über 5 bis 10 Jahre.

```js
const interestTimeline = [
  { label: "2020 Ø", rate: 1.0908, phase: "annual" },
  { label: "2021 Ø", rate: 1.0917, phase: "annual" },
  { label: "2022 Ø", rate: 2.4092, phase: "annual" },
  { label: "2023 Ø", rate: 3.73, phase: "annual" },
  { label: "2024 Ø", rate: 3.5283, phase: "annual" },
  { label: "Jan–Jul 25 Ø", rate: 3.4429, phase: "annual" },
  { label: "Aug 25", rate: 3.56, phase: "monthly" },
  { label: "Sep 25", rate: 3.60, phase: "monthly" },
  { label: "Okt 25", rate: 3.58, phase: "monthly" },
  { label: "Nov 25", rate: 3.56, phase: "monthly" },
  { label: "Dez 25", rate: 3.58, phase: "monthly" },
  { label: "Jan 26", rate: 3.58, phase: "monthly" },
  { label: "Feb 26", rate: 3.61, phase: "monthly" },
  { label: "Mrz 26", rate: 3.60, phase: "monthly" },
  { label: "Apr 26", rate: 3.66, phase: "monthly" },
  { label: "Mai 26", rate: 3.74, phase: "monthly" },
  { label: "Jun 26", rate: 3.76, phase: "monthly" },
  { label: "Jul 26", rate: 3.78, phase: "monthly" },
  { label: "Aug 26", rate: 4.285, phase: "scenario" },
  { label: "Sep 26", rate: 4.79, phase: "scenario" },
  { label: "Okt 26", rate: 5.295, phase: "scenario" },
  { label: "Nov 26", rate: 5.80, phase: "scenario" },
];
```

August bis November 2026 sind eine lineare Verhandlungsannahme bis 5,8 % und keine Marktprognose. Vergleichsbasis der Mehrbelastung: „2021 Ø".

## 3. Gebäudegewerke (Referenzwerte, ⚠️ Orientierung; Stand 09/2026)

```js
const buildingTrades = [
  { id: "roof", number: 1, label: "Dach dämmen und neu eindecken", category: "Gebäudehülle", unit: "m²", quantity: 160, quantityMin: 0, quantityMax: 800, quantityStep: 5, price: 280, priceMin: 0, priceMax: 600, priceStep: 5, active: true },
  { id: "facade", number: 2, label: "Fassade, WDVS, Putz und Gerüst", category: "Gebäudehülle", unit: "m²", quantity: 330, quantityMin: 0, quantityMax: 1500, quantityStep: 5, price: 197, priceMin: 0, priceMax: 400, priceStep: 1, active: true },
  { id: "windows", number: 3, label: "Fenster und Außentüren", category: "Gebäudehülle", unit: "Stk.", quantity: 30, quantityMin: 0, quantityMax: 120, quantityStep: 1, price: 1080, priceMin: 0, priceMax: 2500, priceStep: 10, active: true },
  { id: "cellar", number: 4, label: "Kellerdecke dämmen", category: "Gebäudehülle", unit: "m²", quantity: 100, quantityMin: 0, quantityMax: 500, quantityStep: 5, price: 50, priceMin: 0, priceMax: 150, priceStep: 1, active: true },
  { id: "heatpump", number: 5, label: "Zentrale Wärmepumpe und Warmwasser", category: "Technik", unit: "Anlage", quantity: 1, quantityMin: 0, quantityMax: 3, quantityStep: 1, price: 52790, priceMin: 0, priceMax: 120000, priceStep: 10, active: true },
  { id: "heating-distribution", number: 6, label: "Heizungsverteilung und Steigleitungen", category: "Technik", unit: "m²", quantity: 296, quantityMin: 0, quantityMax: 1500, quantityStep: 5, price: 70, priceMin: 0, priceMax: 200, priceStep: 5, active: false },
  { id: "plumbing", number: 7, label: "Trink- und Abwasserstränge", category: "Technik", unit: "Pauschale", quantity: 1, quantityMin: 0, quantityMax: 5, quantityStep: 1, price: 20000, priceMin: 0, priceMax: 100000, priceStep: 500, active: false },
  { id: "main-electric", number: 8, label: "Hauptverteilung und Allgemeinstrom", category: "Elektro", unit: "Pauschale", quantity: 1, quantityMin: 0, quantityMax: 5, quantityStep: 1, price: 12000, priceMin: 0, priceMax: 60000, priceStep: 500, active: false },
  { id: "other-building", number: 9, label: "Sonstige Gebäudearbeiten", category: "Sonstiges", unit: "Pauschale", quantity: 1, quantityMin: 0, quantityMax: 10, quantityStep: 1, price: 0, priceMin: 0, priceMax: 250000, priceStep: 1000, active: false },
];
```

Die fünf aktiven Gewerke ergeben genau 200.000 € (44.800 + 65.010 + 32.400 + 5.000 + 52.790).

## 4. Wohnungsgewerke

```js
const apartmentTrades = [
  { id: "surface-heating", number: 1, label: "Flächenheizung in den Wohnungen", category: "Heizung", unit: "m²", quantity: 296, quantityMin: 0, quantityMax: 1500, quantityStep: 5, price: 110, priceMin: 0, priceMax: 250, priceStep: 5, active: false, sync: "area" },
  { id: "bathrooms", number: 2, label: "Bäder komplett erneuern", category: "Sanitär", unit: "Bad", quantity: 6, quantityMin: 0, quantityMax: 60, quantityStep: 1, price: 30000, priceMin: 0, priceMax: 60000, priceStep: 500, active: false, sync: "units" },
  { id: "apartment-electric", number: 3, label: "Elektroinstallation Wohnungen", category: "Elektro", unit: "m²", quantity: 296, quantityMin: 0, quantityMax: 1500, quantityStep: 5, price: 130, priceMin: 0, priceMax: 300, priceStep: 5, active: false, sync: "area" },
  { id: "surfaces", number: 4, label: "Boden-, Putz- und Malerarbeiten", category: "Innenausbau", unit: "m²", quantity: 296, quantityMin: 0, quantityMax: 1500, quantityStep: 5, price: 100, priceMin: 0, priceMax: 300, priceStep: 5, active: false, sync: "area" },
  { id: "interior-doors", number: 5, label: "Innentüren", category: "Innenausbau", unit: "Stk.", quantity: 30, quantityMin: 0, quantityMax: 300, quantityStep: 1, price: 600, priceMin: 0, priceMax: 1800, priceStep: 50, active: false, sync: "doors" },
  { id: "kitchens", number: 6, label: "Küchen", category: "Ausstattung", unit: "Küche", quantity: 6, quantityMin: 0, quantityMax: 60, quantityStep: 1, price: 12000, priceMin: 0, priceMax: 35000, priceStep: 500, active: false, sync: "units" },
  { id: "other-apartment", number: 7, label: "Sonstige Wohnungsarbeiten", category: "Sonstiges", unit: "Pauschale", quantity: 1, quantityMin: 0, quantityMax: 10, quantityStep: 1, price: 0, priceMin: 0, priceMax: 250000, priceStep: 1000, active: false },
];
```

`sync`: `area` → vermietbare Fläche, `units` → Wohneinheiten, `doors` → Wohneinheiten × 5.

## 5. Sanierungspakete (Version 2.0)

```js
const buildingPackages = [
  { id: "low", label: "gering", trades: ["roof", "cellar"] },                                                   // 49.800 €
  { id: "medium", label: "mittel", trades: ["roof", "facade", "windows", "cellar", "heatpump"] },               // 200.000 € (Standard)
  { id: "high", label: "umfassend", trades: ["roof", "facade", "windows", "cellar", "heatpump", "heating-distribution", "plumbing", "main-electric"] }, // 252.720 €
];
const apartmentPackages = [
  { id: "none", label: "keine", trades: [] },                                                                    // 0 € (Standard)
  { id: "surfaces", label: "Oberflächen", trades: ["surfaces"] },                                                // 29.600 €
  { id: "full", label: "Bäder, Oberflächen, Elektro", trades: ["bathrooms", "surfaces", "apartment-electric"] }, // 248.080 €
];
const evidenceLevels = [
  { id: "documented", label: "belegt (Angebot liegt vor)", marker: "📄" },
  { id: "likely", label: "wahrscheinlich (Schätzung nach Besichtigung)", marker: "" },
  { id: "precautionary", label: "vorsorglich", marker: "⚠️" },
];
```

Beträge bei 296 m² und 6 Wohneinheiten. Standard: Gebäude `medium`, Wohnungen `none` → 200.000 €.

## 6. Mietrechtliche Deckel (Nutzerannahmen)

```js
const rentCapRates = { increaseCapNormal: 20, increaseCapTight: 15, newLetSurcharge: 10 };
```

## 7. AfA-Sätze nach § 7 Abs. 4 EStG (⚠️ Orientierung)

```js
const afaRates = [
  { maxYearBuilt: 1924, rate: 2.5 },
  { maxYearBuilt: 2022, rate: 2.0 },
  { maxYearBuilt: Infinity, rate: 3.0 },
];
```

## 8. Warnschwellen (fest im Code)

```js
const thresholds = {
  operatingCostPerSqmMin: 1.0,          // ⚠️ Orientierung: nicht umlagefähige BK + Verwaltung unter 1,00 €/m² gilt als knapp
  operatingCostPerSqmMax: 3.0,
  boilerAgeYearsMax: 30,                // GEG § 72: Prüfpunkt, keine automatische Rechtsfolge
  notaryHintPriceBelow: 100000,         // GNotKG: degressive Gebühren, 2 % zu niedrig
  rentAreaTolerance: 0.05,              // m², Abweichung Mietliste ↔ Stammdaten
  rentBuffer: 15,
};
```
`````

### references/PRD.md

`````
# Product Requirements Document: MFH-Kaufpreis-Rechner

## 1. Dokumentstatus

| Feld | Wert |
|---|---|
| Produkt | Geführter Kaufpreis-Rechner für den Ankauf eines Mehrfamilienhauses |
| Version | 2.0 (09.09.2026) — 5.0 war ein Vollrechner mit Referenz-App; 2.0 ist spezifikationsgetrieben, Schnellrechnung + Detailprüfung |
| Sprache | Deutsch |
| Datenstand | 7. September 2026 |
| Nutzer | Käufer/Investor (primär), Verkäufer im Ankaufsgespräch (sekundär) |
| Plattform | Desktop-Browser, iPad/Safari |
| Auslieferung | statische Single-Page-App ohne Backend (`dist/index.html`, `dist/styles.css`, `dist/app.js`) |

## 2. Leitidee

**Schnellrechnung:** vier Schritte Objekt · Miete · Kosten · Ergebnis, etwa zehn Eingaben, Ergebnis in unter zwei Minuten. **Detailprüfung:** derselbe Rechner mit „Erweiterte Annahmen" je Schritt: Mietliste je Wohnung, Gewerke mit Zustandsschätzung (inkl. Dach-Sonderregel), Evidenz und Beleg je Gewerk, Finanzierungsdetails, Zinsdiagramm. **Verkäuferansicht:** fünf Stationen mit Preisbrücke, Korridor und Einigungsoptionen (§12). Ein Rechenkern (`calculation-spec.md`).

## 3. Abgrenzung zum ETW-Rechner

| Thema | ETW | MFH (dieses Produkt) |
|---|---|---|
| Kaufgegenstand | Sondereigentum + MEA | ganzes Gebäude mit Grundstück |
| Gemeinschaftseigentum | Sonderumlagen anteilig | Käufer saniert selbst: Gebäudegewerke mit Zustandsschätzung, Dach-Sonderregel |
| Wohnungssanierung | eine Wohnung | n Wohnungen: Wohnungsgewerke mit Mengen-Sync (Fläche, Einheiten, Türen) |
| Mieten | eine Einheit | Ø-Miete in der Schnellrechnung, Mietliste je Wohnung in der Detailprüfung |
| Laufende Kosten | Hausgeld-Zerlegung | nicht umlagefähige Betriebskosten + Verwaltung je m² |
| Rücklage/WEG | Rücklage, Checkliste | entfällt; stattdessen Gebäudehinweise (Statik, Brandschutz, Denkmalschutz) |
| JSON | `etw-kaufpreisrechner` v2 | `mfh-kaufpreisrechner` v1; alte `kaufpreis-rechner`-Dateien (v1–5) werden abgelehnt |

## 4. Kernliste

1. Wizard mit vier Schritten, Fortschritt, Zusammenfassungen, „Mehr"-Menü (JSON, Drucken, Vollbild, Startwerte).
2. Umschalter Schnellrechnung / Detailprüfung.
3. Objekt: Bezeichnung, Bundesland, vermietbare Fläche, Wohneinheiten, Baujahr; Detail: Grundstück, Geschosse, Flächenquelle, Heizung + Baujahr Wärmeerzeuger, Energieausweis, Adresse, Verkäufertyp, Kontakt.
4. Miete: Ist-Miete, erwartete Miete, Mietbasis; Detail: Mietspiegel, angespannter Markt, Mietliste je Wohnung (Fläche, Ist, Ziel, Mietspiegel, vermietet/leer, „Aus Stammdaten neu verteilen"), Garagen, Stellplätze, Gewerbe, Sonstiges.
5. Kosten: Objektkosten (nicht umlagefähig, Verwaltung je m²), Gebäudepaket und Wohnungspaket per Regler, Finanzierung (Zins, Tilgung, Eigenkapital, Käuferprovision); Detail: 9 Gebäude- und 7 Wohnungsgewerke mit Menge, Basispreis, Zustandsschätzung, GEG-Risiko, Evidenz, Beleg, „vom Verkäufer nicht anerkannt", „Verkäufer saniert vor Übergang"; Reserven; Puffer; Notar; Provisionsrechner; weitere Nebenkosten; Inventar; „Mengen aus Objekt übernehmen".
6. Ergebnis: drei Primärzahlen, Rechenweg in drei Zeilen, Angebotspreis mit Ampel, drei Szenario-Schaltflächen, vier Kennzahlen, Hinweisliste, drei Aktionen; Detail: Korridor, alle Kennzahlen, Szenario-Regler, Zinsdiagramm, Vergleichswert, Nebenkosten-Aufschlüsselung.
7. Verkäuferansicht in fünf Stationen mit Preisbrücke, Evidenzstufen, Entlastungen, Rechnung ohne nicht anerkannte Positionen, Korridor, Tragfähigkeit, Einigungsoptionen mit Übernahme-Delta je Gewerk, Kaufpreisindikation; Ein-Seiten-Druck mit höchstens sechs Zahlen.
8. JSON v1 (`mfh-kaufpreisrechner`), Präsentationsmodus, Druck, Clipboard, Startwerte.
9. Automatische Hinweise nach `calculation-spec.md` §16.

## 5. Informationsarchitektur

| Schritt | ID | Aufgabe | Pflicht (Schnell) |
|---|---|---|---|
| 1 Objekt | `stepObject` | „Welches Haus?" | objectName, area, units, yearBuilt; federalState sichtbar, nicht blockierend |
| 2 Miete | `stepRent` | „Was trägt die Miete?" | currentRentPerSqm, rentPerSqm, rentBasis |
| 3 Kosten | `stepCosts` | „Was kommt dazu?" | nonRecoverableCostPerSqm, managementCostPerSqm, buildingPackage, apartmentPackage, interest, repayment, equity, brokerBuyerRate |
| 4 Ergebnis | `stepResult` | „Welcher Kaufpreis ist tragfähig?" | askingPrice optional |

Verkäuferansicht: 1 Ausgangspunkt · 2 belegte Belastungen (Preisbrücke) · 3 Unsicherheiten und Entlastungen · 4 Korridor · 5 Einigungsoptionen mit Indikation; Zusammenfassungsblatt für den Druck.

## 6. Eingaben und Verhalten

Zahlenfelder mit Einheit; Regler nur für die beiden Pakete und die Szenarien; Nullwerte = „nicht berücksichtigt"; Plausibilitätshinweise am Feld; keine Freitextnotizen im Hauptablauf, je Gewerk ein Beleg-Feld (≤ 200 Zeichen) in der Detailprüfung; keine Schemata.

## 7. Schritt 1 — Objekt

Schnell: `objectName` („Mehrfamilienhaus"), `federalState`, `area` 296, `units` 6, `yearBuilt` 1935. Detail (`advancedObject`): `plotArea` 0, `floors` 3, `areaSource` (+ Hinweis), `heatingType`, `heatingYear`, Energie (4 Felder), `street`, `postalCode`, `city`, `sellerType` (offen/privat/Gesellschaft/Erbengemeinschaft), Kontakt (6 Felder), `afaHint`.

## 8. Schritt 2 — Miete

Schnell: `currentRentPerSqm` 10, `rentPerSqm` 10, `rentBasis` (erwartet/Ist). Ausgaben `residentialRent`, `monthlyRent`, `rentHint`. Detail (`advancedRent`): `rentCapPerSqm` 10, `tightMarket`, Mietliste `rentUnitList` (je Wohnung Bezeichnung, Fläche, Ist, Ziel, Mietspiegel, Status) mit `syncRentUnits` („Aus Stammdaten neu verteilen") und `rentRollAreaStatus`, `garageRent`, `parkingRent`, `commercialRent`, `otherRentLabel`, `otherRent`. Änderung von `units` erzeugt die Zeilenzahl neu; nicht bearbeitete Listen folgen den globalen Werten (v5-Logik).

## 9. Schritt 3 — Kosten

Objektkosten: `nonRecoverableCostPerSqm` 1,00, `managementCostPerSqm` 0,50, Ausgabe `operatingCosts`, `operatingCostHint`. Pakete: `buildingPackage` (Regler 0–2, Standard 1) mit `buildingPackageLabel`/`buildingRenovationTotal`; `apartmentPackage` (Regler 0–2, Standard 0) mit `apartmentPackageLabel`/`apartmentRenovationTotal`; `renovationTotal`. Finanzierung: `interest` 4,8, `repayment` 3, `equity` 0, `brokerBuyerRate` 0; `transferTaxRateOutput`, `purchaseCostsRateTotal`, `purchaseCostsStateHint`, `investmentTotal`.

Detail (`advancedCosts`): `buildingTradeList` (9 Karten) und `apartmentTradeList` (7 Karten), je Karte Aktivierung, Menge, Basispreis, `<details>` Zustandsschätzung (Fragen aus §10, GEG-Risiko), „angepasste Schätzung" mit Begründung, Evidenz, Beleg, „nicht anerkannt", „Verkäufer saniert vor Übergang"; `syncApartmentQuantities`; `buildingReserve`, `apartmentReserve`; `rentBuffer` 15; `notaryLandRegistryRate` 2 (+ `notaryHint`); Provisionsrechner (`brokerCommissionNetRate`, `brokerVatRate`, `brokerBuyerShareRate`, `applyBrokerButton`); `otherPurchaseCostsRate`; `inventoryValue`.

## 10. Zustandsfragen (je Gewerk drei; Dach vier)

Dach: `insulated` (Ja), `ceiling_alternative` (Offen / Ja, bereits gedämmt / Nein, nicht möglich / Möglich; positiv Ja und Möglich), `asbestos` (Nein), `covering` (Ja). Fassade: `insulated` (Ja), `cracks` (Nein), `render` (Ja). Fenster: `insulating_glass`, `tight`, `recent` (alle Ja). Keller: `ceiling_insulated` (Ja), `moisture` (Nein), `sealing` (Ja). Wärmepumpe: `central`, `reusable`, `hot_water` (Ja). Verteilung: `risers` (Ja), `radiators` (Ja), `replacement` (Nein). Stränge: `renewed` (Ja), `lead` (Nein), `damage` (Nein). Elektro Haus: `rcd`, `three_wire`, `distribution` (Ja). Sonstiges Gebäude: `structure`, `fire_protection`, `heritage` (Nein). Flächenheizung: `floor_structure`, `height`, `vacant` (Ja). Bäder: `modernized` (Ja), `moisture` (Nein), `waterproofing` (Ja). Elektro Wohnungen: `rcd`, `three_wire`, `circuits` (Ja). Oberflächen: `floors` (Ja), `plaster` (Ja), `pollutants` (Nein). Innentüren: `functional` (Ja), `fire_doors` (Ja), `replacement` (Nein). Küchen: `included`, `usable`, `tenant_owned` (Ja). Sonstige Wohnung: `damage` (Nein), `backlog` (Nein), `documentation` (Ja). Je Antwort ein Klartext-Befund; GEG-Risiko → „GEG-Risiko oder energetische Nachrüstpflicht ist markiert".

## 11. Schritt 4 — Ergebnis

`maxPurchasePrice`, `totalProjectCost`, `monthlyRate`; `stepBudget`, `stepDeductions` („− Nebenkosten − Gebäude − Wohnungen"), `stepPrice`; `askingPrice`, `askingVerdict`, `askingDelta`; Szenario-Schaltflächen; `kpiGrid` (Bruttorendite, Faktor, Kaufpreis je m², Überschuss); `hintsList`; `calculationWarning`; `actionCompare`, `actionShare`, `actionSave`. Detail: `corridorLow`/`corridorHigh`, `kpiGridFull` (inkl. €/WE, Nettomietrendite, Sanierung je m²/WE, AfA), acht Regler, `buyerInterestChart` + `chartLoan`, `marketPricePerSqm` + `marketDelta`, `purchaseCostsBreakdown`.

## 12. Verkäuferansicht (fünf Stationen)

Wie ETW 2.0 §12, mit MFH-Inhalten: Station 1 Ausgangspunkt (Angebot oder Korridor), positive Punkte (positive Befunde, Verkäufer-Sanierungen); Station 2 Preisbrücke Budget → − Nebenkosten → − Gebäude → − Wohnungen = Korridor oben → − Risikopuffer = Korridor unten; Belastungsliste je Gewerk mit Evidenz (belegt / wahrscheinlich / vorsorglich ±20 %), Beleg, „Basis → angepasst"; Station 3 vorsorgliche Positionen als Bandbreite, nicht anerkannte mit Zweitrechnung, Entlastungen (Dachalternative oberste Geschossdecke ausdrücklich, Verkäufer-Sanierung), Satz zu positiven Unterlagen; Station 4 Korridor, Tragfähigkeit, Schlüsselsatz, Ampel; Station 5 drei Optionen, Übernahme-Delta je Gewerk, Indikation mit Abschlusssatz. Druck: Zusammenfassungsblatt mit ≤ 6 Zahlen.

## 13. Kaufpreisindikation

Anrede, Objekt/Adresse, auf 100 € gerundeter Kaufpreis, Fläche und Wohneinheiten, Kaltmiete, verfügbare Rate, Zins/Tilgung, Sanierung (Gebäude/Wohnungen), Nebenkosten (Inventar gesondert), Energie, Vorbehalte (Finanzierung, Objekt- und Mietunterlagen, Mietverträge/Kautionen § 566/§ 566a BGB, bei Erbengemeinschaft Zustimmung aller Erben, Lastenfreistellung, keine neuen Erkenntnisse), 14 Tage (1–60), keine Kauf-/Verkaufspflicht, § 311b BGB, Abschlusssatz „Auf dieser transparenten Grundlage können wir kurzfristig zu X € kaufen, vorbehaltlich Unterlagen- und Finanzierungsprüfung."

## 14. JSON, Vollbild, Druck, Barrierefreiheit, Datenschutz

Nach `data-schema.md`. Import → alle Schritte frei, Anzeige Schritt 4. Präsentationsmodus mit `exitPresentationButton` und Escape. A11y und Datenschutz wie ETW 2.0.

## 15. Rechtliche Leitplanken (wörtlich in die UI)

Disclaimer: „Orientierung, keine Finanzierungs-, Rechts-, Steuer- oder Anlageberatung. Die Rechnung verwendet eine anfängliche Annuität, pauschale Sanierungsansätze und die eingestellte Grunderwerbsteuer; Ertragsteuern, Förderungen, Wertänderungen und individuelle Bankvorgaben sind nicht berücksichtigt. Aussagen zu Mietverhältnissen, Grundbuchlasten und Baurecht sind Hinweise auf Prüfbedarf, keine Rechtsauskunft." Mietspiegel, Kappung, Bremse = Nutzerannahmen; GEG = Risikohinweis; Sanierungspreise = Orientierung mit sichtbarer Herkunft „Basis → angepasst"; Indikation unverbindlich, § 311b; Ampel = Lage zum Korridor; keine „garantiert"/„rechtssicher"/„vollkommen sicher".

## 16. Nicht im Umfang

Eigennutzer-Modus, Backend, Mehrbenutzer, Bankangebote, Mietspiegel-/Grundbuchabfrage, amtliche GEG-Prüfung, Fördermittel, Steuer, bindende Verträge, Gebäude- oder Grundrissschemata.

## 17. Releasekriterien

1. `validate_app.py` und `node --check` grün; Abnahmetests erfüllt.
2. Startzustand mit NRW: 118.908 € Obergrenze, 329.015 € Gesamtinvestition, 2.139 € Monatsrate; Gebäudepaket mittel 200.000 €, Wohnungspaket keine 0 €.
3. Wizard, Modus, „Mehr"-Menü; Mietliste in der Detailprüfung mit Deckel je Wohnung.
4. Dach-Sonderregel: „oberste Geschossdecke möglich" senkt die angepasste Schätzung sichtbar auf 55 €/m².
5. Verkäuferansicht mit fünf Stationen, geschlossener Preisbrücke, Ampel, Übernahme-Delta; Ein-Seiten-Druck.
6. JSON v1 roundtrip; alte `kaufpreis-rechner`-Dateien und ETW-Dateien mit Klartext abgelehnt.
7. Indikation mit 14 Tagen, § 311b BGB, Abschlusssatz.
`````

### references/sources.md

`````
# Quellen, Annahmen und Aktualisierung

## 1. Bauzinsen

- Deutsche Bundesbank, MFI-Zinsstatistik, Wohnungsbaukredite an private Haushalte, anfängliche Zinsbindung über 5 bis 10 Jahre: <https://api.statistiken.bundesbank.de/rest/data/BBIM1/M.DE.B.A2C.A.C.A.2250.EUR.N?format=csv&lang=de>

Stand der Reihe in `data-tables.md`: 02.09.2026. Werte bis Juli 2026 sind Istwerte bzw. Mittelwerte; August bis November 2026 sind eine lineare Verhandlungsannahme bis 5,8 %, keine Marktprognose. Bei Aktualisierung: letzte zwölf verfügbare Monate monatlich, davor Jahres-/Teiljahresmittel, danach vier klar als Annahme markierte Monate.

## 2. Grunderwerbsteuer

- § 11 GrEStG (Steuersatz, Länderöffnung): <https://www.gesetze-im-internet.de/grestg_1983/__11.html>
- § 8, § 9 GrEStG (Bemessungsgrundlage, Gegenleistung): <https://www.gesetze-im-internet.de/grestg_1983/__8.html>, <https://www.gesetze-im-internet.de/grestg_1983/__9.html>
- Länderübersicht mit Quellenhinweisen (aktualisiert 14.07.2026): <https://meinrechner.eu/ratgeber/grunderwerbsteuer-bundeslaender-2026/>
- Deutsches Notarinstitut, Arbeitshilfen Steuerrecht: <https://www.dnoti.de/arbeitshilfen/steuerrecht/>
- BFH, Urteil vom 16.09.2020, II R 49/17: Eine mitübergehende Instandhaltungsrücklage mindert die Bemessungsgrundlage nicht.

Modellgrenze: Die App rechnet Grunderwerbsteuer als `(Kaufpreis − Inventar) × Ländersatz`. Inventarabzug setzt einen nachvollziehbaren, angemessenen Wert im Kaufvertrag voraus; das prüft die App nicht. Vor produktiver Nutzung alle 16 Sätze und deren Wirksamkeitsdatum prüfen.

## 3. Grundstückskauf, Notar, Makler

- § 311b BGB (notarielle Beurkundung): <https://www.gesetze-im-internet.de/bgb/__311b.html>
- GNotKG (degressive Gebührentabelle; 2 % Notar/Grundbuch ist bei kleinen Kaufpreisen zu niedrig): <https://www.gesetze-im-internet.de/gnotkg/>

## 4. Erbengemeinschaft und Verkäufertyp

- § 2038, § 2040 BGB (Verwaltung und Verfügung nur gemeinschaftlich): <https://www.gesetze-im-internet.de/bgb/__2040.html> — bei einer Erbengemeinschaft als Verkäufer müssen alle Erben mitwirken; die App gibt nur den Vorbehalt aus.

## 5. Objektkosten

- Betriebskostenverordnung (was auf Mieter umlegbar ist; Verwaltung und Instandhaltung nicht): <https://www.gesetze-im-internet.de/betrkv/>

Der Orientierungswert 1,00 €/m² nicht umlagefähige Betriebskosten + 0,50 €/m² Verwaltung stammt aus der Referenz-App 5.0 und ist eine ⚠️-Annahme, keine Statistik.

## 6. Mietrecht

- § 556d BGB (Mietpreisbremse), § 558 BGB (Vergleichsmiete, Kappungsgrenze), § 557a/§ 557b (Staffel-/Indexmiete), § 566/§ 566a (Eintritt in den Mietvertrag, Kaution), § 577 (Vorkaufsrecht des Mieters), § 577a (Kündigungsbeschränkung nach Umwandlung): <https://www.gesetze-im-internet.de/bgb/>
- § 250 BauGB (Umwandlungsgenehmigung): <https://www.gesetze-im-internet.de/bbaug/__250.html>

Die App wendet Kappungsgrenze und Mietpreisbremse nur als rechnerische Deckel auf Nutzerannahmen an. Ob ein Gebiet „angespannt" ist, welche Landesverordnung gilt und ob Ausnahmen (Neubau, Modernisierung) greifen, wird nicht geprüft.

## 7. Bauträger

- Makler- und Bauträgerverordnung (MaBV), insbesondere § 3 (Ratenzahlung nach Baufortschritt): <https://www.gesetze-im-internet.de/gewo_34cdv/>

## 8. GEG und Energieausweis

- § 71 GEG (65 % erneuerbare Energien, Übergangsfristen), § 72 (Betriebsverbot alter Heizkessel): <https://www.gesetze-im-internet.de/geg/>
- § 80 ff. GEG (Energieausweis, Pflichtangaben)

GEG-Markierungen der App sind manuelle Risikomarker; der 10 %-Zuschlag ist eine kaufmännische Sicherheitsannahme.

## 9. Wohnfläche, Steuern

- Wohnflächenverordnung § 4 (Balkone 25 %, höchstens 50 %): <https://www.gesetze-im-internet.de/woflv/__4.html>
- § 7 Abs. 4 EStG (AfA-Sätze): <https://www.gesetze-im-internet.de/estg/__7.html> — nur Orientierung, die App macht keine Kaufpreisaufteilung.

## 10. Aktualisierungsregel

Werden Zins- oder Steuersätze, Bänder oder Schwellen geändert, sind gleichzeitig anzupassen:

- `dist/app.js` (Konstanten),
- sichtbarer Standhinweis in `dist/index.html`,
- `references/data-tables.md`,
- Rechenbeispiel in `references/calculation-spec.md` §17,
- betroffene Abnahmetests in `references/acceptance-tests.md`.
`````

### references/ui-spec.md

`````
# UI-Vertrag 2.0 (MFH) — Struktur, Pflicht-IDs, Verhalten

`scripts/validate_app.py` prüft die hier genannten IDs, Reihenfolgen und Marker.

## 1. Dateien und Technik

- `dist/index.html` (`lang="de"`, Viewport mit `viewport-fit=cover`, verweist auf `./styles.css` und `./app.js` mit `defer`, keine externen Ressourcen).
- `dist/styles.css` beginnt mit `[hidden] { display: none !important; }`.
- `dist/app.js`: ES2020, keine Abhängigkeiten, kein `eval`/`new Function`, Nutzertexte nur per `textContent`/Formularwert.

## 2. Seitenskelett

Wie ETW 2.0: `header#appHeader` (Marke, `stepProgress`, `modeToggle`, `moreMenuButton` → `moreMenu` mit `exportJsonButton`, `importJsonButton`, `printButton`, `fullscreenButton`, `resetButton`), `exitPresentationButton`, `jsonFileInput`, `dataStatus`, `main#top` mit `stepNav` (vier `button[data-step]`), `form#calculatorForm` mit `section#stepObject`, `#stepRent`, `#stepCosts`, `#stepResult` (Quelltextreihenfolge), `p.disclaimer`, `dialog#sellerDialog`. Jeder Schritt: Überschrift, Zusammenfassungszeile(n) der vorigen Schritte, Pflichtfelder, `<details class="advanced" id="advanced<Step>">`, Navigation (`backFrom<Step>`, `nextFrom<Step>`).

## 3. Wizard und Modus

Wie ETW 2.0 §3: nur aktiver Schritt sichtbar, „Weiter" prüft Pflichtfelder (Bundesland nicht blockierend, Warnung), `modeToggle` blendet `details.advanced` ein/aus (`inputs.mode`), `moreMenu` mit Escape; Escape beendet sonst den Präsentationsmodus; im Dialog `cancel` so behandeln, dass der Dialog offen bleibt.

## 4. Bedienmuster

### 4.1 Zahlenfelder
Alle Felder aus `data-schema.md` als `<input>`/`<select>` mit `id` = Schlüssel, Einheit im Feldrahmen, „Pflicht"/„optional". Regler nur `buildingPackage`, `apartmentPackage` (range 0–2) und die acht Szenario-Regler (Zahl + `<id>Range`).

### 4.2 Mietliste (Detailprüfung)
`rentUnitList` (Container) mit Kopfzeile und je Wohnung `<div class="rent-unit-row" data-unit-index>`: Bezeichnung (`input.unit-label`), Fläche (`.unit-area`), Ist (`.unit-current`), Ziel (`.unit-target`), Mietspiegel (`.unit-cap`), Status (`select.unit-tenancy` vermietet/leer), Ergebnis (`.unit-result`: wirksame Miete und bindende Grenze). Button `syncRentUnits`, Status `rentRollAreaStatus`. Auf schmalen Screens scrollt die Liste lokal. Solange die Liste nicht bearbeitet wurde, folgt sie den globalen Feldern und `units`.

### 4.3 Gewerkkarten (Detailprüfung)
`buildingTradeList` und `apartmentTradeList` mit `<article class="trade-card" data-trade-id>`: Checkbox `<id>-active`, Menge `<id>-quantity`, Basispreis `<id>-price`, `<details>` Zustandsschätzung mit `<select id="<id>-check-<frage>">` (Dach: `ceiling_alternative` mit vier Optionen), Checkbox `<id>-geg-risk`, Ausgabe `<id>-adjusted` („Basis → angepasst · Begründung"), `<select id="<id>-evidence">`, `<input id="<id>-source">` (maxlength 200), Checkbox `<id>-disputed` („vom Verkäufer nicht anerkannt"), Checkbox `<id>-seller` („Verkäufer saniert vor Übergang"), Summe `<id>-total`. Manuelle Änderung → Paket „individuell" (`data-custom="true"` am Regler). `syncApartmentQuantities` setzt Mengen nach `sync`.

### 4.4 Hinweise
`hintsList` + `hintsCount` (＋ / − / ⚠). Statusmeldung `dataStatus` (`is-error`).

## 5. Pflicht-IDs

**Shell:** `appHeader`, `stepProgress`, `modeToggle`, `moreMenuButton`, `moreMenu`, `exportJsonButton`, `importJsonButton`, `printButton`, `fullscreenButton`, `resetButton`, `exitPresentationButton`, `jsonFileInput`, `dataStatus`, `stepNav`, `calculatorForm`.

**Schritt 1 (`stepObject`):** `objectName`, `federalState`, `area`, `units`, `yearBuilt`, `advancedObject`, `plotArea`, `floors`, `areaSource`, `areaSourceHint`, `heatingType`, `heatingYear`, `heatingHint`, `energyCertificateStatus`, `energyCertificateType`, `energyClass`, `energyCertificateValidUntil`, `street`, `postalCode`, `city`, `sellerType`, `sellerSalutation`, `sellerFirstName`, `sellerLastName`, `sellerCompany`, `sellerPhone`, `sellerEmail`, `afaHint`, `nextFromObject`.

**Schritt 2 (`stepRent`):** `summaryObject`, `currentRentPerSqm`, `rentPerSqm`, `rentBasis`, `residentialRent`, `monthlyRent`, `rentHint`, `advancedRent`, `rentCapPerSqm`, `tightMarket`, `rentUnitList`, `syncRentUnits`, `rentRollAreaStatus`, `garageRent`, `parkingRent`, `commercialRent`, `otherRentLabel`, `otherRent`, `additionalRent`, `backFromRent`, `nextFromRent`.

**Schritt 3 (`stepCosts`):** `summaryRent`, `nonRecoverableCostPerSqm`, `managementCostPerSqm`, `operatingCosts`, `operatingCostHint`, `buildingPackage`, `buildingPackageLabel`, `buildingRenovationTotal`, `apartmentPackage`, `apartmentPackageLabel`, `apartmentRenovationTotal`, `renovationTotal`, `interest`, `repayment`, `equity`, `brokerBuyerRate`, `transferTaxRateOutput`, `purchaseCostsRateTotal`, `purchaseCostsStateHint`, `investmentTotal`, `advancedCosts`, `buildingTradeList`, `apartmentTradeList`, `syncApartmentQuantities`, `buildingReserve`, `apartmentReserve`, `rentBuffer`, `notaryLandRegistryRate`, `notaryHint`, `brokerCommissionNetRate`, `brokerVatRate`, `brokerBuyerShareRate`, `applyBrokerButton`, `otherPurchaseCostsRate`, `inventoryValue`, `backFromCosts`, `nextFromCosts`.

**Schritt 4 (`stepResult`):** `summaryCosts`, `maxPurchasePrice`, `totalProjectCost`, `monthlyRate`, `stepBudget`, `stepDeductions`, `stepPrice`, `askingPrice`, `askingVerdict`, `askingDelta`, `scenarioRealistic`, `scenarioOptimistic`, `scenarioConservative`, `scenarioNote`, `kpiGrid`, `hintsList`, `hintsCount`, `calculationWarning`, `actionCompare`, `actionShare`, `actionSave`, `advancedResult`, `corridorLow`, `corridorHigh`, `kpiGridFull`, `optimisticRentIncrease`, `optimisticInterestReduction`, `optimisticRenovationReduction`, `optimisticCostReduction`, `conservativeRentReduction`, `conservativeInterestIncrease`, `conservativeRenovationIncrease`, `conservativeCostIncrease`, `buyerInterestChart`, `buyerInterestChartRate`, `buyerInterestChartDelta`, `chartLoan`, `marketPricePerSqm`, `marketDelta`, `purchaseCostsBreakdown`, `backFromResult`.

Range-Paare: die acht Szenario-Regler (`<id>Range`).

## 6. Kennzahlen

`kpiGrid`: Bruttorendite · Kaufpreisfaktor · Kaufpreis je m² · monatlicher Überschuss. `kpiGridFull`: alle aus `calculation-spec.md` §13.

## 7. Verkäuferdialog (fünf Stationen)

Identische Struktur und IDs wie ETW 2.0 §7: `.seller-start` (`sellerGoal`, `sellerStartObject`, `sellerPositives`, `sellerStartPrice`, `sellerStartPriceLabel`), `.seller-burdens` (`sellerBridge` mit `bridgeBudget`, `bridgeCosts`, `bridgeRenovation` (Gebäude), `bridgeApartments` (Wohnungen), `bridgeCorridorHigh`, `bridgeBuffer`, `bridgeCorridorLow`; `sellerBurdenList`, `sellerBurdenTotal`), `.seller-uncertainty` (`sellerUncertainList`, `sellerReliefList`, `sellerWithoutDisputed`, `sellerDocsWouldRaise`), `.seller-corridor` (`sellerCorridorLow`, `sellerCorridorHigh`, `sellerCoverageText`, `sellerKeySentence`, `sellerAskingVerdict`), `.seller-options` (`sellerOptionPrice`, `sellerTakeoverList`, `sellerOptionClarify`, `offerValidityDays`, `purchaseOfferText`, `copyPurchaseOffer`, `sellerClosing`), `#sellerSummarySheet` (`sheetStart`, `sheetBurden`, `sheetCorridorLow`, `sheetCorridorHigh`, `sheetRate`, `sheetAskingDelta`, `sheetOptions`, `sheetReservation`). Toolbar: `sellerFullscreenButton`, `sellerPrintButton`, `sellerCloseButton`, `sellerDataStatus`.

## 8–10. Vollbild, Druck, Zinsdiagramm

Wie ETW 2.0 §8–10 (Präsentationsmodus `html.presentation-mode`, `100dvh`, `exitPresentationButton`, Escape; Druck `@page A4`, `body.print-seller`, nur `sellerSummarySheet`; `renderInterestChart("buyerInterestChart", …)` mit Tastaturbedienung).

## 11. Design-DNA

Wie ETW 2.0 §11 (Dunkelblau/Petrol/Off-White, Gold sparsam, Rot Risiko, Ampel Grün/Gelb/Rot; ≥ 16 px; Touch ≥ 44 px; Wizard-Karte ≤ 720 px, Detail bis 1100 px; Dialog `min(1200px, 100vw − 2rem)`).

## 12. Verhalten

`calculate()` in einem Durchlauf; Szenario-Schaltflächen schalten Primärzahlen; `resetAll()`; Export `kaufpreis-mfh-<YYYY-MM-DD>.json`; Import ≤ 1 MB, Whitelists, Ablehnung `kaufpreis-rechner` (v1–5) und `etw-kaufpreisrechner` mit Klartext; Clipboard mit Fallback.

## 13. Pflichtkonstanten und -funktionen (Validator-Marker)

```
const jsonSchema = "mfh-kaufpreisrechner";
const jsonVersion = 1;
const globalDefaults = { … }
const textDefaults = { … }
const transferTaxRates = { … }
const interestTimeline = [ … ]
const buildingTrades = [ … ]          // data-tables §3
const apartmentTrades = [ … ]         // data-tables §4
const buildingPackages = [ … ]
const apartmentPackages = [ … ]
const evidenceLevels = [ … ]
const inspectionCatalog = { … }       // PRD §10, Dach mit ceiling_alternative
function getEffectiveTradePrice(trade, state, basePrice)   // §7.3 inkl. Dach-Sonderregel
function readRentUnits()                                   // §2 Deckel je Wohnung
function solvePrice({ loan, equity, renovation, taxRate, otherRate, inventory })
function calculateScenario({ … })
function computeMetrics(price, context)
function buildPriceBridge(context)
function buildPurchaseOfferText(data)                      // enthält „§ 311b BGB"
function renderInterestChart(containerId, principal, repayment, selectedRate)
function showStep(n)
function calculate()
```

Zeichenketten `MFH-Kaufpreisrechner` (Ablehnungstext für alte/fremde Dateien), `GEG-Risiko`, `oberste Geschossdecke` müssen vorkommen.
`````

### scripts/validate_app.py

`````
#!/usr/bin/env python3
"""Validate a generated MFH purchase-price calculator (v2.0) against references/ui-spec.md."""

from __future__ import annotations

import argparse
import re
import shutil
import subprocess
from collections import Counter
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import urlsplit


REQUIRED_FILES = (Path("dist/index.html"), Path("dist/styles.css"), Path("dist/app.js"))

SHELL_IDS = {
    "appHeader", "stepProgress", "modeToggle", "moreMenuButton", "moreMenu", "exportJsonButton",
    "importJsonButton", "printButton", "fullscreenButton", "resetButton", "exitPresentationButton",
    "jsonFileInput", "dataStatus", "stepNav", "calculatorForm",
}
OBJECT_IDS = {
    "stepObject", "objectName", "federalState", "area", "units", "yearBuilt", "advancedObject", "plotArea", "floors",
    "areaSource", "areaSourceHint", "heatingType", "heatingYear", "heatingHint", "energyCertificateStatus",
    "energyCertificateType", "energyClass", "energyCertificateValidUntil", "street", "postalCode", "city",
    "sellerType", "sellerSalutation", "sellerFirstName", "sellerLastName", "sellerCompany", "sellerPhone",
    "sellerEmail", "afaHint", "nextFromObject",
}
RENT_IDS = {
    "stepRent", "summaryObject", "currentRentPerSqm", "rentPerSqm", "rentBasis", "residentialRent", "monthlyRent",
    "rentHint", "advancedRent", "rentCapPerSqm", "tightMarket", "rentUnitList", "syncRentUnits", "rentRollAreaStatus",
    "garageRent", "parkingRent", "commercialRent", "otherRentLabel", "otherRent", "additionalRent", "backFromRent",
    "nextFromRent",
}
COST_IDS = {
    "stepCosts", "summaryRent", "nonRecoverableCostPerSqm", "managementCostPerSqm", "operatingCosts",
    "operatingCostHint", "buildingPackage", "buildingPackageLabel", "buildingRenovationTotal", "apartmentPackage",
    "apartmentPackageLabel", "apartmentRenovationTotal", "renovationTotal", "interest", "repayment", "equity",
    "brokerBuyerRate", "transferTaxRateOutput", "purchaseCostsRateTotal", "purchaseCostsStateHint", "investmentTotal",
    "advancedCosts", "buildingTradeList", "apartmentTradeList", "syncApartmentQuantities", "buildingReserve",
    "apartmentReserve", "rentBuffer", "notaryLandRegistryRate", "notaryHint", "brokerCommissionNetRate",
    "brokerVatRate", "brokerBuyerShareRate", "applyBrokerButton", "otherPurchaseCostsRate", "inventoryValue",
    "backFromCosts", "nextFromCosts",
}
RESULT_IDS = {
    "stepResult", "summaryCosts", "maxPurchasePrice", "totalProjectCost", "monthlyRate", "stepBudget",
    "stepDeductions", "stepPrice", "askingPrice", "askingVerdict", "askingDelta", "scenarioRealistic",
    "scenarioOptimistic", "scenarioConservative", "scenarioNote", "kpiGrid", "hintsList", "hintsCount",
    "calculationWarning", "actionCompare", "actionShare", "actionSave", "advancedResult", "corridorLow",
    "corridorHigh", "kpiGridFull", "optimisticRentIncrease", "optimisticInterestReduction",
    "optimisticRenovationReduction", "optimisticCostReduction", "conservativeRentReduction",
    "conservativeInterestIncrease", "conservativeRenovationIncrease", "conservativeCostIncrease",
    "buyerInterestChart", "buyerInterestChartRate", "buyerInterestChartDelta", "chartLoan", "marketPricePerSqm",
    "marketDelta", "purchaseCostsBreakdown", "backFromResult",
}
SELLER_IDS = {
    "sellerDialog", "sellerFullscreenButton", "sellerPrintButton", "sellerCloseButton", "sellerDataStatus",
    "sellerGoal", "sellerStartObject", "sellerPositives", "sellerStartPrice", "sellerStartPriceLabel", "sellerBridge",
    "bridgeBudget", "bridgeCosts", "bridgeRenovation", "bridgeApartments", "bridgeCorridorHigh", "bridgeBuffer",
    "bridgeCorridorLow", "sellerBurdenList", "sellerBurdenTotal", "sellerUncertainList", "sellerReliefList",
    "sellerWithoutDisputed", "sellerDocsWouldRaise", "sellerCorridorLow", "sellerCorridorHigh", "sellerCoverageText",
    "sellerKeySentence", "sellerAskingVerdict", "sellerOptionPrice", "sellerTakeoverList", "sellerOptionClarify",
    "offerValidityDays", "purchaseOfferText", "copyPurchaseOffer", "sellerClosing", "sellerSummarySheet",
    "sheetStart", "sheetBurden", "sheetCorridorLow", "sheetCorridorHigh", "sheetRate", "sheetAskingDelta",
    "sheetOptions", "sheetReservation",
}
REQUIRED_IDS = SHELL_IDS | OBJECT_IDS | RENT_IDS | COST_IDS | RESULT_IDS | SELLER_IDS
RANGE_PAIR_IDS = (
    "optimisticRentIncrease", "optimisticInterestReduction", "optimisticRenovationReduction", "optimisticCostReduction",
    "conservativeRentReduction", "conservativeInterestIncrease", "conservativeRenovationIncrease", "conservativeCostIncrease",
)
BUILDING_IDS = ("roof", "facade", "windows", "cellar", "heatpump", "heating-distribution", "plumbing", "main-electric", "other-building")
APARTMENT_IDS = ("surface-heating", "bathrooms", "apartment-electric", "surfaces", "interior-doors", "kitchens", "other-apartment")
REQUIRED_JS_MARKERS = (
    'const jsonSchema = "mfh-kaufpreisrechner";', "const jsonVersion = 1;", "const globalDefaults = {", "const textDefaults = {",
    "const transferTaxRates = {", "const interestTimeline = [", "const buildingTrades = [", "const apartmentTrades = [",
    "const buildingPackages = [", "const apartmentPackages = [", "const evidenceLevels = [", "const inspectionCatalog = {",
    "function getEffectiveTradePrice(", "function readRentUnits(", "function solvePrice(", "function calculateScenario(",
    "function computeMetrics(", "function buildPriceBridge(", "function buildPurchaseOfferText(",
    "function renderInterestChart(", "function showStep(", "function calculate(", 'renderInterestChart("buyerInterestChart"',
    "§ 311b BGB", "GEG-Risiko", "oberste Geschossdecke", "MFH-Kaufpreisrechner", "ceiling_alternative",
)
STEP_ORDER = ("stepObject", "stepRent", "stepCosts", "stepResult")
SELLER_MARKERS = ("seller-start", "seller-burdens", "seller-uncertainty", "seller-corridor", "seller-options", 'id="sellerSummarySheet"')


class AppHTMLParser(HTMLParser):
    def __init__(self) -> None:
        super().__init__(convert_charrefs=True)
        self.ids: list[str] = []
        self.local_assets: list[str] = []
        self.external_assets: list[str] = []

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        a = dict(attrs)
        if a.get("id"):
            self.ids.append(a["id"])
        cand = a.get("src") if tag == "script" else (a.get("href") if tag == "link" and (a.get("rel") or "").lower() == "stylesheet" else None)
        if cand:
            (self.external_assets if urlsplit(cand).scheme or cand.startswith("//") else self.local_assets).append(cand)


def extract_object_keys(js: str, name: str) -> list[str]:
    m = re.search(rf"const {re.escape(name)} = \{{(.*?)\n\}};", js, re.S)
    return re.findall(r"^\s{2}\"?([A-Za-z][A-Za-z0-9_-]*)\"?:", m.group(1), re.M) if m else []


def trade_totals(js: str, name: str) -> dict[str, tuple[float, bool]]:
    m = re.search(rf"const {re.escape(name)} = \[(.*?)\n\];", js, re.S)
    out: dict[str, tuple[float, bool]] = {}
    if not m:
        return out
    for item in re.findall(r"\{\s*(.*?)\}", m.group(1), re.S):
        cid = re.search(r'id:\s*"([a-z-]+)"', item)
        q = re.search(r"\bquantity:\s*([0-9.]+)", item)
        p = re.search(r"\bprice:\s*([0-9.]+)", item)
        a = re.search(r"\bactive:\s*(true|false)", item)
        if cid and q and p and a:
            out[cid.group(1)] = (float(q.group(1)) * float(p.group(1)), a.group(1) == "true")
    return out


def main() -> int:
    parser = argparse.ArgumentParser(description="Validate an MFH purchase-price calculator project (v2.0).")
    parser.add_argument("project", type=Path)
    root = parser.parse_args().project.expanduser().resolve()
    errors: list[str] = []
    notes: list[str] = []

    for rel in REQUIRED_FILES:
        if not (root / rel).is_file():
            errors.append(f"Missing required file: {rel}")
    if errors:
        for e in errors:
            print(f"ERROR: {e}")
        return 1

    html_path, css_path, js_path = root / "dist/index.html", root / "dist/styles.css", root / "dist/app.js"
    html, css, js = (p.read_text(encoding="utf-8") for p in (html_path, css_path, js_path))

    p = AppHTMLParser()
    try:
        p.feed(html)
        p.close()
    except Exception as exc:
        errors.append(f"HTML parsing failed: {exc}")
    dupes = sorted(i for i, c in Counter(p.ids).items() if c > 1)
    if dupes:
        errors.append(f"Duplicate HTML ids: {', '.join(dupes)}")
    html_ids = set(p.ids)
    missing = sorted(REQUIRED_IDS - html_ids)
    if missing:
        errors.append(f"Missing required UI ids ({len(missing)}): {', '.join(missing)}")
    miss_r = sorted(f"{b}Range" for b in RANGE_PAIR_IDS if f"{b}Range" not in html_ids)
    if miss_r:
        errors.append(f"Missing range inputs for scenario sliders: {', '.join(miss_r)}")

    referenced = set(re.findall(r'document\.getElementById\("([^"]+)"\)', js))
    referenced.update(extract_object_keys(js, "globalDefaults"))
    referenced.update(extract_object_keys(js, "textDefaults"))
    generated = {f"{t}-{s}" for t in BUILDING_IDS + APARTMENT_IDS for s in ("active", "quantity", "price", "geg-risk", "adjusted", "evidence", "source", "disputed", "seller", "total")}
    generated.update({"buyerInterestChartSvgTitle", "buyerInterestChartSvgDesc", "mode", "scenarioSelected", "rentUnitsCustomized"})
    miss_dom = sorted(referenced - html_ids - generated)
    if miss_dom:
        errors.append(f"JavaScript references ids that are neither in HTML nor generated per ui-spec: {', '.join(miss_dom)}")

    if p.external_assets:
        errors.append(f"External scripts/stylesheets are not allowed: {', '.join(p.external_assets)}")
    for asset in p.local_assets:
        if not (html_path.parent / urlsplit(asset).path).resolve().is_file():
            errors.append(f"Missing local asset referenced by HTML: {asset}")

    order = [html.find(f'id="{i}"') for i in STEP_ORDER]
    if any(x < 0 for x in order) or order != sorted(order):
        errors.append("Step order is not stepObject → stepRent → stepCosts → stepResult.")
    ds = html.find('id="sellerDialog"')
    if ds < 0:
        errors.append("Seller dialog missing.")
    else:
        so = [html.find(m, ds) for m in SELLER_MARKERS]
        if any(x < 0 for x in so) or so != sorted(so):
            errors.append("Seller station order is not start → burdens → uncertainty → corridor → options → summary sheet.")

    for m in REQUIRED_JS_MARKERS:
        if m not in js:
            errors.append(f"Missing JavaScript feature marker: {m}")
    for group, ids in (("building trade", BUILDING_IDS), ("apartment trade", APARTMENT_IDS)):
        for i in ids:
            if f'"{i}"' not in js and f"'{i}'" not in js:
                errors.append(f"{group} id missing in app.js: {i}")

    if re.search(r"\beval\s*\(|\bnew\s+Function\s*\(", js):
        errors.append("Unsafe dynamic code execution found in JavaScript.")
    if re.search(r"innerHTML\s*\+?=\s*(?:readText|readInput|readValue|.*\.value\b)", js):
        errors.append("Potential user-controlled innerHTML assignment found.")

    depth = mn = 0
    for ch in re.sub(r"/\*.*?\*/", "", css, flags=re.S):
        if ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
            mn = min(mn, depth)
    if depth != 0 or mn < 0:
        errors.append("CSS braces are unbalanced.")
    for cm in ("presentation-mode", "100dvh", "@media print", "print-seller", "@page", "[hidden]"):
        if cm not in css:
            errors.append(f"CSS marker missing: {cm}")

    b = trade_totals(js, "buildingTrades")
    if not b:
        errors.append("Could not read buildingTrades (id/quantity/price/active per trade).")
    else:
        active_total = sum(v for v, a in b.values() if a)
        if abs(active_total - 200000) > 0.01:
            errors.append(f"Default active building trades must total 200000, got {active_total:.2f}.")
        else:
            notes.append("Default building package medium: 200000 EUR")
    a_tr = trade_totals(js, "apartmentTrades")
    if not a_tr:
        errors.append("Could not read apartmentTrades.")
    elif any(a for _, a in a_tr.values()):
        errors.append("Default apartment package must be 'none' (no active apartment trade).")
    else:
        notes.append("Default apartment package none: 0 EUR")

    node = shutil.which("node")
    if node:
        r = subprocess.run([node, "--check", str(js_path)], capture_output=True, text=True, check=False)
        if r.returncode != 0:
            errors.append(f"JavaScript syntax check failed: {r.stderr.strip()}")
        else:
            notes.append("JavaScript syntax: OK")
    else:
        notes.append("Node unavailable; JavaScript syntax check skipped")

    if errors:
        for e in errors:
            print(f"ERROR: {e}")
        for n in notes:
            print(f"NOTE: {n}")
        return 1
    for n in notes:
        print(f"OK: {n}")
    print(f"OK: {len(html_ids)} unique HTML ids; all required ids, markers and assets present")
    print("Validation passed.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
`````

<!-- porthor:skill v1 · https://porthor.de/skills -->
