diff --git a/pitch-deck/exports/Finanzplan-1Mio-Euro-Base.xlsx b/pitch-deck/exports/Finanzplan-1Mio-Euro-Base.xlsx index 068ffd9..4ccb38e 100644 Binary files a/pitch-deck/exports/Finanzplan-1Mio-Euro-Base.xlsx and b/pitch-deck/exports/Finanzplan-1Mio-Euro-Base.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-1Mio-Euro-Bear.xlsx b/pitch-deck/exports/Finanzplan-1Mio-Euro-Bear.xlsx index ae1b335..90e5095 100644 Binary files a/pitch-deck/exports/Finanzplan-1Mio-Euro-Bear.xlsx and b/pitch-deck/exports/Finanzplan-1Mio-Euro-Bear.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-1Mio-Euro-Bull.xlsx b/pitch-deck/exports/Finanzplan-1Mio-Euro-Bull.xlsx index 9accdae..b145b2a 100644 Binary files a/pitch-deck/exports/Finanzplan-1Mio-Euro-Bull.xlsx and b/pitch-deck/exports/Finanzplan-1Mio-Euro-Bull.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-Series-A-Ambitioniert.xlsx b/pitch-deck/exports/Finanzplan-Series-A-Ambitioniert.xlsx new file mode 100644 index 0000000..ba35741 Binary files /dev/null and b/pitch-deck/exports/Finanzplan-Series-A-Ambitioniert.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-Wandeldarlehen-200k.xlsx b/pitch-deck/exports/Finanzplan-Wandeldarlehen-200k.xlsx index 8663500..0360c13 100644 Binary files a/pitch-deck/exports/Finanzplan-Wandeldarlehen-200k.xlsx and b/pitch-deck/exports/Finanzplan-Wandeldarlehen-200k.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-Wandeldarlehen-400k-Bear.xlsx b/pitch-deck/exports/Finanzplan-Wandeldarlehen-400k-Bear.xlsx new file mode 100644 index 0000000..3d97e54 Binary files /dev/null and b/pitch-deck/exports/Finanzplan-Wandeldarlehen-400k-Bear.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-Wandeldarlehen-400k-Bull.xlsx b/pitch-deck/exports/Finanzplan-Wandeldarlehen-400k-Bull.xlsx new file mode 100644 index 0000000..d1d4e6c Binary files /dev/null and b/pitch-deck/exports/Finanzplan-Wandeldarlehen-400k-Bull.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-Wandeldarlehen-400k.xlsx b/pitch-deck/exports/Finanzplan-Wandeldarlehen-400k.xlsx new file mode 100644 index 0000000..a0c8dad Binary files /dev/null and b/pitch-deck/exports/Finanzplan-Wandeldarlehen-400k.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bear.xlsx b/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bear.xlsx index fbfa5e2..d67caf1 100644 Binary files a/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bear.xlsx and b/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bear.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bull.xlsx b/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bull.xlsx index 1c58837..20d70ea 100644 Binary files a/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bull.xlsx and b/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bull.xlsx differ diff --git a/pitch-deck/scripts/add-inflation-formulas.py b/pitch-deck/scripts/add-inflation-formulas.py new file mode 100644 index 0000000..1d3a0c1 --- /dev/null +++ b/pitch-deck/scripts/add-inflation-formulas.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +"""Add inflation formulas to 'Betriebliche Aufwendungen' rows + update Büromiete. + +What this does: + 1. Update row 27 (Büromiete): 0 in Aug 2026, then 1000 €/Monat from Sep 2026 to + Dec 2026 (hardcoded), then inflation-adjusted formulas from Jan 2027 onwards. + 2. Add Treiber driver block 'Inflation Betriebskosten' (one rate per year). + 3. For each eligible row, keep 2026 values hardcoded (the 'Startwert') and + replace cells from Jan 2027 onwards with a formula that: + - carries the previous month's value forward in non-January months + - multiplies by (1 + inflation_for_this_year) every January + +Eligibility (which rows get inflation formulas): + - Row contains numeric values (not formulas) — formula rows like KFZ that + scale via Personalkosten are left alone. + - Row has at least 2 non-zero months in 2026 (constant or 'later activation' + patterns: D&O insurance, Recruiting, Buchführung, etc.). + - Yearly-fee rows (exactly 1 non-zero month in 2026 — e.g., IHK in Sep) + get a year-over-year carry: same month next year = last year × (1+inflation), + other months stay 0. + - Rows where all 2026 values are 0 are skipped. + - Subtotal/summe rows are formulas already → automatically skipped. + +Usage: + python3 pitch-deck/scripts/add-inflation-formulas.py --dry-run + python3 pitch-deck/scripts/add-inflation-formulas.py +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from datetime import datetime +from pathlib import Path + +from openpyxl import load_workbook +from openpyxl.utils import get_column_letter + +EXPORTS = Path(__file__).resolve().parent.parent / "exports" + +BUEROMIETE_ROW = 27 +BUEROMIETE_START_MONTH = 9 # September +BUEROMIETE_VALUE = 1000 + +DEFAULT_INFLATION = 0.03 + +_BA_SHEET = "Betriebliche Aufwendungen" + + +def year_columns(ws) -> dict[int, list[int]]: + out: dict[int, list[int]] = {} + for c in range(2, ws.max_column + 1): + v = ws.cell(row=1, column=c).value + if v is None: + continue + try: + yr = int(v) + except (TypeError, ValueError): + continue + out.setdefault(yr, []).append(c) + return out + + +def cols_by_year_month(ws) -> dict[tuple[int, int], int]: + """Return {(year, month): column_index} based on rows 1 and 2.""" + out: dict[tuple[int, int], int] = {} + for c in range(2, ws.max_column + 1): + y = ws.cell(row=1, column=c).value + m = ws.cell(row=2, column=c).value + try: + out[(int(y), int(m))] = c + except (TypeError, ValueError): + continue + return out + + +# --------------------------------------------------------------------------- +# Treiber: add or read inflation driver block +# --------------------------------------------------------------------------- + +INFLATION_HEADER = "Inflation Betriebskosten" + + +def find_or_create_inflation_block(ws_t, years_after_start: list[int]) -> dict[int, int]: + """Find existing inflation block by header, or append a new one. Return {year: row}.""" + # Look for existing header + header_row = None + for r in range(1, ws_t.max_row + 1): + if ws_t.cell(row=r, column=1).value == INFLATION_HEADER: + header_row = r + break + + if header_row is None: + # Append new block + header_row = ws_t.max_row + 2 # blank then header + ws_t.cell(row=header_row, column=1).value = INFLATION_HEADER + + refs: dict[int, int] = {} + r = header_row + 1 + for yr in years_after_start: + label_cell = ws_t.cell(row=r, column=1) + if label_cell.value is None or not str(label_cell.value).startswith("Inflation"): + # Fresh entry + ws_t.cell(row=r, column=1).value = f"Inflation {yr}" + ws_t.cell(row=r, column=2).value = DEFAULT_INFLATION + # If already there, preserve existing value + refs[yr] = r + r += 1 + return refs + + +# --------------------------------------------------------------------------- +# Büromiete special handling (row 27) +# --------------------------------------------------------------------------- + + +def update_bueromiete(ws_ba, col_index: dict, base_year: int, inflation_refs: dict[int, int]) -> int: + """Set row 27: 0 in Aug 2026, 1000 from Sep 2026 to Dec 2026 (hardcoded), + then inflation formula from Jan 2027 onwards. + """ + inc_first = min(inflation_refs.values()) + inc_last = max(inflation_refs.values()) + + # Keep label free of the word 'Inflation' — number-formatting heuristics + # treat that as a percent indicator and would mis-format Euro cells. + ws_ba.cell(row=BUEROMIETE_ROW, column=1).value = "Büromiete (ab Sep 2026)" + + written = 0 + for c in range(2, ws_ba.max_column + 1): + y = ws_ba.cell(row=1, column=c).value + m = ws_ba.cell(row=2, column=c).value + if y == base_year: + # Hardcoded 2026 values + val = BUEROMIETE_VALUE if m >= BUEROMIETE_START_MONTH else 0 + ws_ba.cell(row=BUEROMIETE_ROW, column=c).value = val + written += 1 + else: + # Inflation formula from Jan 2027 onwards + col_letter = get_column_letter(c) + prev_col = get_column_letter(c - 1) + ws_ba.cell(row=BUEROMIETE_ROW, column=c).value = ( + f"=IF(AND({col_letter}$2=1,{col_letter}$1>{base_year})," + f"{prev_col}{BUEROMIETE_ROW}*(1+INDEX(Treiber!$B${inc_first}:$B${inc_last},{col_letter}$1-{base_year}))," + f"{prev_col}{BUEROMIETE_ROW})" + ) + written += 1 + return written + + +# --------------------------------------------------------------------------- +# General inflation application to qualifying rows +# --------------------------------------------------------------------------- + + +def is_formula(value) -> bool: + return isinstance(value, str) and value.startswith("=") + + +def collect_eligible_rows(ws_ba, base_year_cols: list[int], skip_rows: set[int]) -> dict[int, str]: + """For each row, decide if it's eligible and classify it. + + Returns {row: kind} where kind is one of: + 'monthly' - ≥2 non-zero months in 2026, apply Jan-inflation carry-forward + 'yearly' - exactly 1 non-zero month in 2026, apply same-month-next-year + (rows not eligible are absent from the dict) + """ + out: dict[int, str] = {} + for r in range(4, ws_ba.max_row + 1): + if r in skip_rows: + continue + label = ws_ba.cell(row=r, column=1).value + if not label: + continue + if str(label).startswith(("summe —", "Summe ")) or "SUMME" in str(label): + continue + + values_2026 = [ws_ba.cell(row=r, column=c).value for c in base_year_cols] + if any(is_formula(v) for v in values_2026): + continue + nonzero_count = sum(1 for v in values_2026 if v not in (None, 0, "")) + if nonzero_count == 0: + continue + if nonzero_count == 1: + out[r] = "yearly" + else: + out[r] = "monthly" + return out + + +def write_monthly_inflation(ws_ba, row: int, first_formula_col: int, base_year: int, + inflation_refs: dict[int, int]) -> int: + """From first_formula_col onwards, carry previous + Jan inflation.""" + inc_first = min(inflation_refs.values()) + inc_last = max(inflation_refs.values()) + written = 0 + for c in range(first_formula_col, ws_ba.max_column + 1): + col_letter = get_column_letter(c) + prev_col = get_column_letter(c - 1) + ws_ba.cell(row=row, column=c).value = ( + f"=IF(AND({col_letter}$2=1,{col_letter}$1>{base_year})," + f"{prev_col}{row}*(1+INDEX(Treiber!$B${inc_first}:$B${inc_last},{col_letter}$1-{base_year}))," + f"{prev_col}{row})" + ) + written += 1 + return written + + +def write_yearly_inflation(ws_ba, row: int, first_formula_col: int, base_year: int, + inflation_refs: dict[int, int], col_index: dict) -> int: + """For yearly-fee rows: same month next year × (1 + inflation), else 0.""" + inc_first = min(inflation_refs.values()) + inc_last = max(inflation_refs.values()) + written = 0 + for c in range(first_formula_col, ws_ba.max_column + 1): + y = ws_ba.cell(row=1, column=c).value + m = ws_ba.cell(row=2, column=c).value + if y is None or m is None: + continue + same_month_prev_year = col_index.get((int(y) - 1, int(m))) + if same_month_prev_year is None: + continue + prev_letter = get_column_letter(same_month_prev_year) + col_letter = get_column_letter(c) + ws_ba.cell(row=row, column=c).value = ( + f"={prev_letter}{row}*(1+INDEX(Treiber!$B${inc_first}:$B${inc_last},{col_letter}$1-{base_year}))" + ) + written += 1 + return written + + +# --------------------------------------------------------------------------- +# Main per-file processing +# --------------------------------------------------------------------------- + + +def process_file(path: Path, dry_run: bool) -> dict | None: + wb = load_workbook(path) + if _BA_SHEET not in wb.sheetnames or "Treiber" not in wb.sheetnames: + return None + ws_ba = wb[_BA_SHEET] + ws_t = wb["Treiber"] + + yc = year_columns(ws_ba) + years = sorted(yc.keys()) + base_year = min(years) + years_after_start = [y for y in years if y > base_year] + + inflation_refs = find_or_create_inflation_block(ws_t, years_after_start) + + col_index = cols_by_year_month(ws_ba) + base_year_cols = yc[base_year] + first_year_after = min(years_after_start) + first_formula_col = col_index.get((first_year_after, 1)) + if first_formula_col is None: + return None + + # Büromiete (always) + bueromiete_cells = update_bueromiete(ws_ba, col_index, base_year, inflation_refs) + + # Determine eligible rows (skip Büromiete since we just handled it) + skip = {BUEROMIETE_ROW} + eligible = collect_eligible_rows(ws_ba, base_year_cols, skip) + + cells_monthly = 0 + cells_yearly = 0 + n_monthly = n_yearly = 0 + for row, kind in eligible.items(): + if kind == "monthly": + cells_monthly += write_monthly_inflation(ws_ba, row, first_formula_col, base_year, inflation_refs) + n_monthly += 1 + else: + cells_yearly += write_yearly_inflation(ws_ba, row, first_formula_col, base_year, inflation_refs, col_index) + n_yearly += 1 + + if not dry_run: + wb.save(path) + + return { + "years": years, + "monthly_rows": n_monthly, + "yearly_rows": n_yearly, + "cells_bueromiete": bueromiete_cells, + "cells_monthly": cells_monthly, + "cells_yearly": cells_yearly, + "eligible_rows": {kind: [r for r, k in eligible.items() if k == kind] for kind in ("monthly", "yearly")}, + } + + +def backup(path: Path) -> Path: + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + bk = path.with_name(f"{path.stem}.BACKUP-pre-inflation-{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("--only", help="Process only this filename") + ap.add_argument("--no-backup", action="store_true") + args = ap.parse_args() + + files = sorted(EXPORTS.glob("Finanzplan-*.xlsx")) + files = [f for f in files if "BACKUP" not in f.name] + if args.only: + files = [f for f in files if f.name == args.only] + + for path in files: + wb_peek = load_workbook(path, read_only=True) + if _BA_SHEET not in wb_peek.sheetnames or "Treiber" not in wb_peek.sheetnames: + print(f"\n ⨯ skip {path.name}: missing sheets") + continue + if not args.dry_run and not args.no_backup: + bk = backup(path) + print(f" ✓ backup: {bk.name}") + info = process_file(path, dry_run=args.dry_run) + if info is None: + continue + print(f"\n === {path.name} ===") + print(f" Büromiete (row {BUEROMIETE_ROW}): {info['cells_bueromiete']} cells (Sep 2026 start)") + print(f" Monthly-inflation rows: {info['monthly_rows']} ({info['cells_monthly']} cells)") + print(f" Yearly-fee rows: {info['yearly_rows']} ({info['cells_yearly']} cells)") + print(f" Eligible monthly rows: {info['eligible_rows']['monthly']}") + print(f" Eligible yearly rows: {info['eligible_rows']['yearly']}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pitch-deck/scripts/add-kunden-formulas.py b/pitch-deck/scripts/add-kunden-formulas.py new file mode 100644 index 0000000..ae724a3 --- /dev/null +++ b/pitch-deck/scripts/add-kunden-formulas.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +"""Replace hard-coded values in 'Kunden' sheet with proper formulas: + +- Neukunden = driver lookup (per year × segment) from Treiber sheet +- Churn = ROUND(previous-month Bestandskunden × Churn-Rate, 0) +- Bestandskunden = previous + new - churn (cumulative) + +Default driver values are derived from each file's existing monthly Neukunden +data so each scenario keeps its growth profile. Churn rates default to +industry-typical B2B SaaS values (Starter 1%, Pro 0.5%, Enterprise 0.3%/Monat). + +Usage: + python3 pitch-deck/scripts/add-kunden-formulas.py --dry-run + python3 pitch-deck/scripts/add-kunden-formulas.py + python3 pitch-deck/scripts/add-kunden-formulas.py --only Finanzplan-Wandeldarlehen-400k.xlsx +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from datetime import datetime +from pathlib import Path + +from openpyxl import load_workbook +from openpyxl.utils import get_column_letter + +EXPORTS = Path(__file__).resolve().parent.parent / "exports" + +# Default monthly churn rates per segment per year. Reflects business reality: +# - Starter: high early churn (testing, startups failing) decreasing as product matures +# - Professional: moderate, gradually decreasing +# - Enterprise: very low — they integrate the product, don't switch +# Annual equivalents: +# Starter 64% → 17%, Professional 26% → 9%, Enterprise 6% → 1% +CHURN_DEFAULTS: dict[str, dict[int, float]] = { + "starter": {2026: 0.08, 2027: 0.05, 2028: 0.03, 2029: 0.02, 2030: 0.015}, + "professional": {2026: 0.025, 2027: 0.02, 2028: 0.015, 2029: 0.01, 2030: 0.008}, + "enterprise": {2026: 0.005, 2027: 0.003, 2028: 0.002, 2029: 0.001, 2030: 0.001}, +} + +# Segment definitions: (segment_name, neukunden_row, churn_row, bestand_row, suffix) +SEGMENTS = [ + ("Starter (<10 MA)", 4, 5, 6, "starter"), + ("Professional (10-250 MA)", 7, 8, 9, "professional"), + ("Enterprise (250+ MA)", 10, 11, 12, "enterprise"), +] + + +def year_columns(ws_kunden) -> dict[int, list[int]]: + out: dict[int, list[int]] = {} + for c in range(2, ws_kunden.max_column + 1): + v = ws_kunden.cell(row=1, column=c).value + if v is None: + continue + try: + yr = int(v) + except (TypeError, ValueError): + continue + out.setdefault(yr, []).append(c) + return out + + +def monthly_avg_per_year(ws_kunden, row: int, year_cols: dict[int, list[int]]) -> dict[int, int]: + """Compute rounded monthly average from existing per-month values. + + Uses half-up rounding (0.5 → 1) instead of Python's banker's rounding (0.5 → 0) + to preserve segments that grew at exactly half-a-customer-per-month. + """ + res: dict[int, int] = {} + for yr, cols in year_cols.items(): + total = 0.0 + for c in cols: + v = ws_kunden.cell(row=row, column=c).value + if v in (None, ""): + continue + try: + total += float(v) + except (TypeError, ValueError): + pass + avg = total / len(cols) if cols else 0 + res[yr] = max(0, int(avg + 0.5)) + return res + + +_SEG_LABEL = {"starter": "Starter", "professional": "Professional", "enterprise": "Enterprise"} + + +def _clear_old_kundenakquise_block(ws_treiber) -> None: + """If we ran a previous version, drop any rows after the original 30 to start fresh.""" + if ws_treiber.max_row > 30: + for r in range(31, ws_treiber.max_row + 1): + ws_treiber.cell(row=r, column=1).value = None + ws_treiber.cell(row=r, column=2).value = None + + +def write_treiber_drivers(ws_treiber, years: list[int], defaults_by_segment: dict[str, dict[int, int]]) -> dict: + """Rewrite the Kundenakquise + Churn driver block. Returns row indices for cross-references. + + Layout (after original row 30): + 31 (blank) + 32 Header "Kundenakquise" + 33..47 Neukunden/Monat per (segment, year) + 48 (blank) + 49 Header "Churn (monatliche Rate, pro Jahr)" + 50..64 Churn-Rate/Monat per (segment, year) + """ + _clear_old_kundenakquise_block(ws_treiber) + + r = 32 + ws_treiber.cell(row=r, column=1).value = "Kundenakquise" + r += 1 + new_customer_refs: dict[str, dict[int, int]] = {} + for suffix in ("starter", "professional", "enterprise"): + new_customer_refs[suffix] = {} + for yr in years: + ws_treiber.cell(row=r, column=1).value = f"Neukunden/Monat {_SEG_LABEL[suffix]} {yr}" + ws_treiber.cell(row=r, column=2).value = defaults_by_segment[suffix][yr] + new_customer_refs[suffix][yr] = r + r += 1 + r += 1 # blank row 48 + ws_treiber.cell(row=r, column=1).value = "Churn (monatliche Rate, pro Jahr)" + r += 1 + churn_refs: dict[str, dict[int, int]] = {} + for suffix in ("starter", "professional", "enterprise"): + churn_refs[suffix] = {} + for yr in years: + ws_treiber.cell(row=r, column=1).value = f"Churn-Rate/Monat {_SEG_LABEL[suffix]} {yr}" + ws_treiber.cell(row=r, column=2).value = CHURN_DEFAULTS[suffix][yr] + churn_refs[suffix][yr] = r + r += 1 + return {"new_customer_rows": new_customer_refs, "churn_rows": churn_refs} + + +# Helper rows in Kunden sheet (added BELOW the totals to avoid breaking external refs). +# Each helper row holds the year-varying monthly churn rate per column for one segment. +HELPER_ROWS = { + "starter": 18, + "professional": 19, + "enterprise": 20, +} + + +def write_kunden_formulas(ws_kunden, years: list[int], refs: dict) -> dict: + min_year = min(years) + new_refs = refs["new_customer_rows"] + churn_refs = refs["churn_rows"] + stats = {"neu": 0, "churn": 0, "bestand": 0, "helper": 0} + + # 1. Write per-segment helper rows (18-20) with year-lookup rates per column. + ws_kunden.cell(row=17, column=1).value = None # separator + for suffix, helper_row in HELPER_ROWS.items(): + first = min(churn_refs[suffix].values()) + last = max(churn_refs[suffix].values()) + ws_kunden.cell(row=helper_row, column=1).value = ( + f"Monatl. Churn-Rate {_SEG_LABEL[suffix]} (Helper)" + ) + for c in range(2, ws_kunden.max_column + 1): + col_letter = get_column_letter(c) + ws_kunden.cell(row=helper_row, column=c).value = ( + f"=INDEX(Treiber!$B${first}:$B${last},{col_letter}$1-{min_year - 1})" + ) + stats["helper"] += 1 + + # 2. Write per-segment Neukunden/Churn/Bestandskunden formulas. + for seg_label, neu_row, churn_row, bestand_row, suffix in SEGMENTS: + ws_kunden.cell(row=neu_row, column=1).value = f"Neukunden {seg_label}" + ws_kunden.cell(row=churn_row, column=1).value = f"Churn {seg_label}" + ws_kunden.cell(row=bestand_row, column=1).value = f"Bestandskunden {seg_label}" + + seg_neu_rows = new_refs[suffix] + neu_first = min(seg_neu_rows.values()) + neu_last = max(seg_neu_rows.values()) + helper_row = HELPER_ROWS[suffix] + + for c in range(2, ws_kunden.max_column + 1): + col_letter = get_column_letter(c) + prev_col = get_column_letter(c - 1) if c > 2 else None + + # --- Neukunden: year-lookup driver --- + ws_kunden.cell(row=neu_row, column=c).value = ( + f"=INDEX(Treiber!$B${neu_first}:$B${neu_last},{col_letter}$1-{min_year - 1})" + ) + stats["neu"] += 1 + + # --- Churn: cumulative-rounding to make small-base churn visible --- + # Approach: expected_cum_churn(t) = SUMPRODUCT(Bestand[B..t-1], Rate[C..t]) + # Each month's churn = ROUND(expected_cum) - already_booked_churn. + # This guarantees integer monthly values that aggregate to ROUND(expected_total). + if c == 2: + # Aug 2026: no prior month, no churn. + ws_kunden.cell(row=churn_row, column=c).value = 0 + elif c == 3: + # Sep 2026: one prior bestand cell (B), no churn-booked yet. + ws_kunden.cell(row=churn_row, column=c).value = ( + f"=MAX(0,ROUND(SUMPRODUCT($B{bestand_row}:B{bestand_row}," + f"$C{helper_row}:C{helper_row}),0))" + ) + else: + ws_kunden.cell(row=churn_row, column=c).value = ( + f"=MAX(0,ROUND(SUMPRODUCT($B{bestand_row}:{prev_col}{bestand_row}," + f"$C{helper_row}:{col_letter}{helper_row}),0)" + f"-SUM($C{churn_row}:{prev_col}{churn_row}))" + ) + stats["churn"] += 1 + + # --- Bestandskunden: cumulative balance --- + if c == 2: + ws_kunden.cell(row=bestand_row, column=c).value = ( + f"={col_letter}{neu_row}-{col_letter}{churn_row}" + ) + else: + ws_kunden.cell(row=bestand_row, column=c).value = ( + f"={prev_col}{bestand_row}+{col_letter}{neu_row}-{col_letter}{churn_row}" + ) + stats["bestand"] += 1 + + return stats + + +def _was_already_processed(ws_treiber) -> bool: + """Detect if a previous run already wrote the Kundenakquise driver block.""" + return ws_treiber.cell(row=32, column=1).value == "Kundenakquise" + + +def _read_existing_neukunden_drivers(ws_treiber, years: list[int]) -> dict: + """Read driver values previously written to Treiber rows 33..47. + + Re-running on a processed file would otherwise compute defaults from + formula cells (which openpyxl returns as strings) and reset everything to 0. + """ + defaults: dict[str, dict[int, int]] = {} + r = 33 + for suffix in ("starter", "professional", "enterprise"): + defaults[suffix] = {} + for yr in years: + v = ws_treiber.cell(row=r, column=2).value + try: + defaults[suffix][yr] = int(round(float(v))) if v is not None else 0 + except (TypeError, ValueError): + defaults[suffix][yr] = 0 + r += 1 + return defaults + + +def process_file(path: Path, dry_run: bool) -> dict | None: + wb = load_workbook(path) + if "Kunden" not in wb.sheetnames or "Treiber" not in wb.sheetnames: + return None # caller will report skip + + ws_k = wb["Kunden"] + ws_t = wb["Treiber"] + + yc = year_columns(ws_k) + years = sorted(yc.keys()) + + if _was_already_processed(ws_t): + # Preserve user-edited driver values from a previous run + defaults = _read_existing_neukunden_drivers(ws_t, years) + source = "treiber (preserved)" + else: + # First time: compute defaults from existing Kunden values + defaults = { + "starter": monthly_avg_per_year(ws_k, 4, yc), + "professional": monthly_avg_per_year(ws_k, 7, yc), + "enterprise": monthly_avg_per_year(ws_k, 10, yc), + } + source = "kunden data" + + refs = write_treiber_drivers(ws_t, years, defaults) + stats = write_kunden_formulas(ws_k, years, refs) + + if not dry_run: + wb.save(path) + + return {"years": years, "defaults": defaults, "stats": stats, "refs": refs, "defaults_source": source} + + +def backup(path: Path) -> Path: + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + bk = path.with_name(f"{path.stem}.BACKUP-pre-kunden-formulas-{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("--only", help="Process only this filename") + ap.add_argument("--no-backup", action="store_true") + args = ap.parse_args() + + files = sorted(EXPORTS.glob("Finanzplan-*.xlsx")) + files = [f for f in files if "BACKUP" not in f.name] + if args.only: + files = [f for f in files if f.name == args.only] + + for path in files: + # Peek first to decide whether to backup + wb_peek = load_workbook(path, read_only=True) + if "Treiber" not in wb_peek.sheetnames or "Kunden" not in wb_peek.sheetnames: + print(f"\n ⨯ skip {path.name}: no Treiber sheet") + continue + if not args.dry_run and not args.no_backup: + bk = backup(path) + print(f" ✓ backup: {bk.name}") + info = process_file(path, dry_run=args.dry_run) + if info is None: + print(f" ⨯ skip {path.name}: structural mismatch") + continue + print(f"\n === {path.name} ===") + print(f" Years: {info['years']}") + print(f" Defaults source: {info['defaults_source']}") + print(" Neukunden/Monat:") + for seg in ("starter", "professional", "enterprise"): + print(f" {seg:13s}: {info['defaults'][seg]}") + print(f" Cells written: {info['stats']}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pitch-deck/scripts/add-price-formulas.py b/pitch-deck/scripts/add-price-formulas.py new file mode 100644 index 0000000..fc63e8e --- /dev/null +++ b/pitch-deck/scripts/add-price-formulas.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Add price-escalation formulas to Finanzplan Excel files. + +Replaces hard-coded Preis/Monat values in 'Umsatzerlöse' rows 4, 7, 10 with: +- Starting price (Aug 2026) read from a Treiber driver +- Annual price increase applied in January of each subsequent year +- Increase percentage configurable per year via Treiber driver + +Treiber layout (appended after existing rows): + blank + 'Preise' + 'Startpreis/Monat Starter' → 300 + 'Startpreis/Monat Professional' → 2083 + 'Startpreis/Monat Enterprise' → 4167 + blank + 'Preiserhöhung pro Jahr (%)' + 'Preiserhöhung 2027' → 0.03 + 'Preiserhöhung 2028' → 0.03 + 'Preiserhöhung 2029' → 0.03 + 'Preiserhöhung 2030' → 0.03 + +Usage: + python3 pitch-deck/scripts/add-price-formulas.py --dry-run + python3 pitch-deck/scripts/add-price-formulas.py + python3 pitch-deck/scripts/add-price-formulas.py --only Finanzplan-Wandeldarlehen-400k.xlsx +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from datetime import datetime +from pathlib import Path + +from openpyxl import load_workbook +from openpyxl.utils import get_column_letter + +EXPORTS = Path(__file__).resolve().parent.parent / "exports" + +# Segment → row in Umsatzerlöse where Preis/Monat lives +PRICE_ROWS = { + "starter": 4, + "professional": 7, + "enterprise": 10, +} + +# Default starting prices (Aug 2026) — used only if existing value isn't a number +DEFAULT_START_PRICES = { + "starter": 300, + "professional": 2083, + "enterprise": 4167, +} + +DEFAULT_PRICE_INCREASE = 0.03 # 3% per year by default + +_SEG_LABEL = {"starter": "Starter", "professional": "Professional", "enterprise": "Enterprise"} + + +def year_columns(ws_kunden) -> dict[int, list[int]]: + out: dict[int, list[int]] = {} + for c in range(2, ws_kunden.max_column + 1): + v = ws_kunden.cell(row=1, column=c).value + if v is None: + continue + try: + yr = int(v) + except (TypeError, ValueError): + continue + out.setdefault(yr, []).append(c) + return out + + +def existing_start_prices(ws_umsatz) -> dict[str, float]: + """Read current Aug 2026 (column B) prices to use as Startpreis defaults.""" + res: dict[str, float] = {} + for suffix, row in PRICE_ROWS.items(): + v = ws_umsatz.cell(row=row, column=2).value + try: + res[suffix] = float(v) if v is not None else float(DEFAULT_START_PRICES[suffix]) + except (TypeError, ValueError): + # Already a formula? Use default. + res[suffix] = float(DEFAULT_START_PRICES[suffix]) + return res + + +def already_processed(ws_treiber) -> tuple[bool, int]: + """Return (already_processed, start_row). start_row is where to place the Preise block.""" + # Scan for an existing 'Preise' header + for r in range(1, ws_treiber.max_row + 1): + if ws_treiber.cell(row=r, column=1).value == "Preise": + return True, r + # First time: place after max_row with a blank separator + return False, ws_treiber.max_row + 2 + + +def read_existing_price_drivers(ws_treiber, header_row: int, years_after_start: list[int]) -> tuple[dict, dict, dict]: + """Read existing price drivers we wrote previously. Returns (start_prices, increases, refs).""" + start_prices: dict[str, float] = {} + increases: dict[int, float] = {} + refs: dict[str, int | dict[int, int]] = {} + + r = header_row + 1 + start_refs: dict[str, int] = {} + for suffix in ("starter", "professional", "enterprise"): + v = ws_treiber.cell(row=r, column=2).value + try: + start_prices[suffix] = float(v) if v is not None else float(DEFAULT_START_PRICES[suffix]) + except (TypeError, ValueError): + start_prices[suffix] = float(DEFAULT_START_PRICES[suffix]) + start_refs[suffix] = r + r += 1 + refs["start_refs"] = start_refs + + r += 1 # blank + r += 1 # 'Preiserhöhung' header + increase_refs: dict[int, int] = {} + for yr in years_after_start: + v = ws_treiber.cell(row=r, column=2).value + try: + increases[yr] = float(v) if v is not None else DEFAULT_PRICE_INCREASE + except (TypeError, ValueError): + increases[yr] = DEFAULT_PRICE_INCREASE + increase_refs[yr] = r + r += 1 + refs["increase_refs"] = increase_refs + + return start_prices, increases, refs + + +def write_treiber_price_drivers(ws_treiber, start_row: int, years_after_start: list[int], + start_prices: dict[str, float], increases: dict[int, float]) -> dict: + r = start_row + ws_treiber.cell(row=r, column=1).value = "Preise" + r += 1 + start_refs: dict[str, int] = {} + for suffix in ("starter", "professional", "enterprise"): + ws_treiber.cell(row=r, column=1).value = f"Startpreis/Monat {_SEG_LABEL[suffix]}" + ws_treiber.cell(row=r, column=2).value = start_prices[suffix] + start_refs[suffix] = r + r += 1 + r += 1 # blank + ws_treiber.cell(row=r, column=1).value = "Preiserhöhung pro Jahr (%)" + r += 1 + increase_refs: dict[int, int] = {} + for yr in years_after_start: + ws_treiber.cell(row=r, column=1).value = f"Preiserhöhung {yr}" + ws_treiber.cell(row=r, column=2).value = increases.get(yr, DEFAULT_PRICE_INCREASE) + increase_refs[yr] = r + r += 1 + return {"start_refs": start_refs, "increase_refs": increase_refs} + + +def write_umsatz_price_formulas(ws_umsatz, refs: dict, base_year: int) -> int: + """Write price formulas to Umsatzerlöse rows 4/7/10 across all month columns. + + Formula structure: + col B (Aug 2026, first month): =Treiber!$B$startpreis + col C+ same year: =prev_col + col where month=1 and year>base_year: + =prev_col*(1+INDEX(Treiber!$B$inc_first:$B$inc_last,year-base_year)) + """ + start_refs = refs["start_refs"] + increase_refs = refs["increase_refs"] + inc_first = min(increase_refs.values()) + inc_last = max(increase_refs.values()) + + cells_written = 0 + for suffix, row in PRICE_ROWS.items(): + # Clean label + ws_umsatz.cell(row=row, column=1).value = f"Preis/Monat ({_SEG_LABEL[suffix]})" + for c in range(2, ws_umsatz.max_column + 1): + col_letter = get_column_letter(c) + if c == 2: + # Aug 2026 starting price + ws_umsatz.cell(row=row, column=c).value = ( + f"=Treiber!$B${start_refs[suffix]}" + ) + else: + prev_col = get_column_letter(c - 1) + ws_umsatz.cell(row=row, column=c).value = ( + f"=IF(AND({col_letter}$2=1,{col_letter}$1>{base_year})," + f"{prev_col}{row}*(1+INDEX(Treiber!$B${inc_first}:$B${inc_last}," + f"{col_letter}$1-{base_year}))," + f"{prev_col}{row})" + ) + cells_written += 1 + return cells_written + + +def process_file(path: Path, dry_run: bool) -> dict | None: + wb = load_workbook(path) + if "Umsatzerlöse" not in wb.sheetnames or "Treiber" not in wb.sheetnames or "Kunden" not in wb.sheetnames: + return None + + ws_u = wb["Umsatzerlöse"] + ws_t = wb["Treiber"] + ws_k = wb["Kunden"] + + yc = year_columns(ws_k) + years = sorted(yc.keys()) + base_year = min(years) + years_after_start = [y for y in years if y > base_year] + + processed, start_row = already_processed(ws_t) + if processed: + # Preserve existing driver values + start_prices, increases, _ = read_existing_price_drivers(ws_t, start_row, years_after_start) + source = "treiber (preserved)" + else: + # First time: read current Aug 2026 prices, default increases + start_prices = existing_start_prices(ws_u) + increases = {y: DEFAULT_PRICE_INCREASE for y in years_after_start} + source = "umsatz column B" + + refs = write_treiber_price_drivers(ws_t, start_row, years_after_start, start_prices, increases) + cells = write_umsatz_price_formulas(ws_u, refs, base_year) + + if not dry_run: + wb.save(path) + + return { + "years": years, + "start_prices": start_prices, + "increases": increases, + "cells": cells, + "source": source, + "refs": refs, + } + + +def backup(path: Path) -> Path: + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + bk = path.with_name(f"{path.stem}.BACKUP-pre-price-formulas-{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("--only", help="Process only this filename") + ap.add_argument("--no-backup", action="store_true") + args = ap.parse_args() + + files = sorted(EXPORTS.glob("Finanzplan-*.xlsx")) + files = [f for f in files if "BACKUP" not in f.name] + if args.only: + files = [f for f in files if f.name == args.only] + + for path in files: + wb_peek = load_workbook(path, read_only=True) + if not all(s in wb_peek.sheetnames for s in ("Umsatzerlöse", "Treiber", "Kunden")): + print(f"\n ⨯ skip {path.name}: missing required sheets") + continue + if not args.dry_run and not args.no_backup: + bk = backup(path) + print(f" ✓ backup: {bk.name}") + info = process_file(path, dry_run=args.dry_run) + if info is None: + continue + print(f"\n === {path.name} ===") + print(f" Source: {info['source']}") + print(f" Startpreise (Aug {min(info['years'])}):") + for seg, val in info["start_prices"].items(): + print(f" {seg:13s}: {val:>8.2f} €/Monat") + print(" Preiserhöhung pro Jahr (Jan jeweils):") + for yr, inc in info["increases"].items(): + print(f" {yr}: {inc*100:.1f}%") + print(f" Cells written: {info['cells']}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pitch-deck/scripts/add-tantieme-and-explanations.py b/pitch-deck/scripts/add-tantieme-and-explanations.py new file mode 100644 index 0000000..a5dacbb --- /dev/null +++ b/pitch-deck/scripts/add-tantieme-and-explanations.py @@ -0,0 +1,279 @@ +#!/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)^(