#!/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())