#!/usr/bin/env python3 """Add Tantieme driver + founder bonus logic + explanation text to Sensitivity/Cohort. Changes per file (Wandeldarlehen-400k Base/Bull/Bear + Series-A): 1. Treiber: append 'Tantieme Gründer' block (2028, 2029, 2030, default 0%). 2. Personalkosten: modify rows 27 (Benjamin) and 28 (Sharang) for columns from Jan 2028 onwards — wrap base brutto in (1 + tantieme_for_year). Cols B-R (Aug 2026 to Dec 2027) stay untouched. 3. Cohort-Analyse: add 'Erläuterung' block at the bottom explaining what the sheet shows and how to read it. 4. Sensitivity: add 'Erläuterung' block explaining baseline column + reading. Usage: python3 pitch-deck/scripts/add-tantieme-and-explanations.py --dry-run python3 pitch-deck/scripts/add-tantieme-and-explanations.py """ from __future__ import annotations import argparse import re import shutil import sys from datetime import datetime from pathlib import Path from openpyxl import load_workbook from openpyxl.styles import Alignment, Font EXPORTS = Path(__file__).resolve().parent.parent / "exports" TARGETS = [ "Finanzplan-Series-A-Ambitioniert.xlsx", "Finanzplan-Wandeldarlehen-400k.xlsx", "Finanzplan-Wandeldarlehen-400k-Bull.xlsx", "Finanzplan-Wandeldarlehen-400k-Bear.xlsx", ] # Founders detected by column-A label ('Benjamin', 'Sharang') — actual row varies # by file (400k: rows 27/28, Series-A: rows 39/40). FOUNDER_NAME_HINTS = ("Benjamin", "Sharang") # Year when Tantieme starts (Jan) TANTIEME_START_YEAR = 2028 TANTIEME_END_YEAR = 2030 TANTIEME_DEFAULT = 0.0 # 0% by default; user sets 0.20 to 1.00 COHORT_EXPLANATION = [ "Erläuterung", "", "Was zeigt diese Tabelle? Jede Zeile entspricht einer Akquise-Kohorte (= alle", "Kunden, die in diesem Monat neu gewonnen wurden). Die Spalten M0, M1, M2 …", "zeigen, wie viele dieser Kunden nach 0, 1, 2 … Monaten noch aktiv sind.", "Die Werte sinken nach Akquise durch Churn (B5 = Retention pro Monat).", "", "Warum nützlich? Die Cohort-Analyse macht sichtbar, wie schnell Kunden", "abwandern und wie lang die durchschnittliche Customer Lifetime ist. Daraus", "ergibt sich der LTV: Lifetime (Monate) × ARPU × Bruttomarge.", "", "Beispiel: Bei monatlicher Churn-Rate 1,5% verbleiben nach 12 Monaten ~83%,", "nach 24 Monaten ~70%. Lifetime ≈ 1 / 0,015 = 67 Monate (~5,6 Jahre).", "Das Beobachtungsfenster ist auf 24 Monate begrenzt (B6 änderbar).", ] SENSITIVITY_EXPLANATION = [ "Erläuterung", "", "Was ist Spalte E? Der 'Baseline'-Wert für EBIT 2030 ($B$5 = GuV!F16). In", "jeder Zeile gleich, weil keine Variable verändert wird = Erwartung wenn", "alle Annahmen wie geplant. Wenn EBIT 2030 nicht sinnvoll erscheint, hängt", "das von Umsatz-, Personal- und Aufwand-Annahmen ab — siehe Treiber-Sheet.", "", "Was zeigen die Spalten -30% bis +30%? Pro Zeile (= eine Variable) wird", "diese eine Annahme um den genannten Prozentsatz variiert. Andere Annahmen", "bleiben Baseline. Ergebnis: EBIT 2030 unter dieser Variation.", "", "Beispiel: Zeile 'Monatliche Churn-Rate', Spalte '+30%'. Bedeutung: Wenn die", "Churn-Rate 30% schlechter ist als geplant, wie hoch wäre der EBIT? Die", "Differenz zur Baseline (E) = Sensitivität dieses Faktors.", "", "Wie interpretieren? Die Variable mit der größten Spannweite zwischen -30%", "und +30% ist am riskantesten (Tornado-Logik). 2D-Heatmap (Row 26+) zeigt", "kombinierte Effekte von Churn × ARPU auf LTV/CAC — gesund: > 3.", "", "Limitierung: One-at-a-Time ignoriert Wechselwirkungen (z.B. ändert sich", "Preis-Erhöhung → auch Churn). Für Investoren ist OAT trotzdem üblich.", ] def add_tantieme_to_treiber(ws_t) -> dict[int, int]: """Append Tantieme driver block after row 81. Returns {year: row}.""" start = ws_t.max_row + 2 # blank then header r = start ws_t.cell(row=r, column=1).value = "Tantieme Gründer (% Brutto, ab Jan 2028)" r += 1 refs: dict[int, int] = {} for yr in range(TANTIEME_START_YEAR, TANTIEME_END_YEAR + 1): ws_t.cell(row=r, column=1).value = f"Tantieme Gründer {yr}" ws_t.cell(row=r, column=2).value = TANTIEME_DEFAULT ws_t.cell(row=r, column=2).number_format = "0%" refs[yr] = r r += 1 return refs def find_jan_2028_col(ws_pk) -> int: for c in range(2, ws_pk.max_column + 1): y = ws_pk.cell(row=1, column=c).value m = ws_pk.cell(row=2, column=c).value if y == 2028 and m == 1: return c raise RuntimeError("Could not locate Jan 2028 column in Personalkosten") # Match the base brutto subexpression we want to wrap: $D$X*(1+$E$X/100)^($1-$G$X) BASE_BRUTTO_RE = re.compile( r"(\$D\$(\d+)\*\(1\+\$E\$\2/100\)\^\(([A-Z]+)\$1-\$G\$\2\))" ) def wrap_with_tantieme(formula: str, tantieme_first: int, tantieme_last: int) -> str | None: """Wrap the base-brutto subexpression with (1 + tantieme_for_year). Returns the new formula, or None if no match or already wrapped (idempotent). """ # Idempotency: skip if a Tantieme multiplier with these rows already exists if f"INDEX(Treiber!$B${tantieme_first}:$B${tantieme_last}" in formula: return None m = BASE_BRUTTO_RE.search(formula) if not m: return None base_expr = m.group(1) col_letter = m.group(3) tantieme_factor = ( f"(1+INDEX(Treiber!$B${tantieme_first}:$B${tantieme_last}," f"{col_letter}$1-{TANTIEME_START_YEAR - 1}))" ) new_expr = f"{base_expr}*{tantieme_factor}" return formula.replace(base_expr, new_expr) def find_founder_brutto_rows(ws_pk) -> list[int]: rows: list[int] = [] for r in range(1, ws_pk.max_row + 1): a = ws_pk.cell(row=r, column=1).value if not isinstance(a, str): continue if "— Brutto" in a and any(hint in a for hint in FOUNDER_NAME_HINTS): rows.append(r) return rows def apply_tantieme_to_personalkosten(ws_pk, refs: dict[int, int]) -> int: jan_2028 = find_jan_2028_col(ws_pk) tantieme_first = min(refs.values()) tantieme_last = max(refs.values()) rows = find_founder_brutto_rows(ws_pk) n = 0 for row in rows: for c in range(jan_2028, ws_pk.max_column + 1): cell = ws_pk.cell(row=row, column=c) if not isinstance(cell.value, str) or not cell.value.startswith("="): continue new_formula = wrap_with_tantieme(cell.value, tantieme_first, tantieme_last) if new_formula is not None: cell.value = new_formula n += 1 return n def add_explanation_block(ws, lines: list[str], start_row: int) -> int: """Write lines starting at start_row, col A. Returns last row written.""" bold = Font(bold=True, size=12) wrap = Alignment(wrap_text=True, vertical="top") r = start_row for line in lines: cell = ws.cell(row=r, column=1, value=line) if line == "Erläuterung": cell.font = bold else: cell.alignment = wrap r += 1 return r - 1 def process_file(path: Path, dry_run: bool) -> dict: wb = load_workbook(path) stats: dict = {} # 1 — Tantieme driver in Treiber if "Treiber" not in wb.sheetnames or "Personalkosten" not in wb.sheetnames: return {"error": "missing required sheets"} # Detect existing Tantieme block to avoid duplicating ws_t = wb["Treiber"] existing_row = None for r in range(1, ws_t.max_row + 1): v = ws_t.cell(row=r, column=1).value if isinstance(v, str) and v.startswith("Tantieme Gründer (%"): existing_row = r break if existing_row: # Already present: read existing references, don't re-append refs = {} r = existing_row + 1 for yr in range(TANTIEME_START_YEAR, TANTIEME_END_YEAR + 1): refs[yr] = r r += 1 stats["tantieme_block"] = "preserved" else: refs = add_tantieme_to_treiber(ws_t) stats["tantieme_block"] = f"added rows {min(refs.values())}-{max(refs.values())}" # 2 — Apply Tantieme to founder Brutto rows ws_pk = wb["Personalkosten"] n_formulas = apply_tantieme_to_personalkosten(ws_pk, refs) stats["formulas_wrapped"] = n_formulas # 3 — Cohort-Analyse explanation if "Cohort-Analyse" in wb.sheetnames: ws_co = wb["Cohort-Analyse"] # Append at row 49 (after content at row 47). Idempotent: don't re-add. existing = any( ws_co.cell(row=r, column=1).value == "Erläuterung" for r in range(48, min(ws_co.max_row + 1, 70)) ) if not existing: last = add_explanation_block(ws_co, COHORT_EXPLANATION, start_row=49) stats["cohort_explanation"] = f"rows 49-{last}" else: stats["cohort_explanation"] = "already present" # 4 — Sensitivity explanation if "Sensitivity" in wb.sheetnames: ws_se = wb["Sensitivity"] existing = any( ws_se.cell(row=r, column=1).value == "Erläuterung" for r in range(38, min(ws_se.max_row + 1, 70)) ) if not existing: last = add_explanation_block(ws_se, SENSITIVITY_EXPLANATION, start_row=39) stats["sensitivity_explanation"] = f"rows 39-{last}" else: stats["sensitivity_explanation"] = "already present" if not dry_run: wb.save(path) return stats def backup(path: Path) -> Path: ts = datetime.now().strftime("%Y%m%d-%H%M%S") bk = path.with_name(f"{path.stem}.BACKUP-pre-tantieme-{ts}{path.suffix}") shutil.copy2(path, bk) return bk def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--dry-run", action="store_true") ap.add_argument("--no-backup", action="store_true") args = ap.parse_args() for name in TARGETS: path = EXPORTS / name if not path.exists(): print(f" ⨯ skip {name}: not found") continue if not args.dry_run and not args.no_backup: bk = backup(path) print(f" ✓ backup: {bk.name}") stats = process_file(path, dry_run=args.dry_run) print(f"\n === {name} ===") for k, v in stats.items(): print(f" {k}: {v}") return 0 if __name__ == "__main__": sys.exit(main())