01e2e0fc4b
8 neue Skripte erweitern die Excel-Finanzpläne deutlich: - add-kunden-formulas: Neukunden-Lookup + kumulativer Churn (SUMPRODUCT-basiert) - add-price-formulas: jährliche Preiserhöhung Jan via Treiber - add-inflation-formulas: Inflation auf Betriebskosten + Büromiete-Logik - add-tantieme-and-explanations: Gründer-Tantieme 2028-2030 + Erläuterungen in Cohort-Analyse + Sensitivity-Sheets - apply-bueromiete: 1000€/Monat ab Sep 2026 mit Inflation - apply-number-formatting: Euro / Count / Percent per Label-Klassifikation - cleanup-finanzplan-labels: 'kategorie — '-Präfix entfernt - copy-extra-sheets: Charts/Cohort/Sensitivity/Hiring-Plan von Series-A auf 400k Base/Bull/Bear übertragen (inkl. 12 Chart-Objekten) Neue Excel-Dateien (für L-Bank Wandeldarlehen 400k Pitch): - Finanzplan-Wandeldarlehen-400k.xlsx (Base) - Finanzplan-Wandeldarlehen-400k-Bull.xlsx - Finanzplan-Wandeldarlehen-400k-Bear.xlsx - Finanzplan-Series-A-Ambitioniert.xlsx (Series-A Variante) Inhaltliche Anpassungen (400k Base/Bull/Bear): - Channel-Provision Bechtle/Cancom → Channel-Partner Provision, Format Euro - GuV: 'Steuerbares Einkommen' → 'Zu versteuerndes Einkommen (nach Verlustvortrag)', Formel um Zinserträge/-aufwand erweitert - IT-Recht/Datenschutzjurist auf 100% (6666 € statt 3333 €) - Series-A-Investor in WD-Sheet auf 0 € (nicht eingeplant in 400k Variante) - Mitarbeiter +1 Monat verschoben (außer Gründer = Okt 2026) - 3 Enterprise-Neukunden zusätzlich (Apr 2027, Jun 2027, Okt 2029) - Marketing-Agentur Cut ~33% pro Szenario (Base 4%, Bull 5%, Bear 2%) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
280 lines
10 KiB
Python
280 lines
10 KiB
Python
#!/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)^(<col>$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())
|