refactor: Consolidate standalone services into admin-v2, add new SDK modules

Remove standalone services (ai-compliance-sdk root, developer-portal,
dsms-gateway, dsms-node, night-scheduler) and legacy compliance/dsgvo pages.
Add new SDK pipeline modules (academy, document-crawler, dsb-portal,
incidents, whistleblower, reporting, sso, multi-tenant, industry-templates).
Add drafting engine, legal corpus files (AT/CH/DE), pitch-deck,
blog and Förderantrag pages.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-15 09:05:18 +01:00
parent 626f4966e2
commit 70f2b0ae64
396 changed files with 43163 additions and 80397 deletions

View File

@@ -0,0 +1,263 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen2.5:32b'
const SYSTEM_PROMPT = `# Investor Agent — BreakPilot ComplAI
## Identitaet
Du bist der BreakPilot ComplAI Investor Relations Agent. Du beantwortest Fragen von
potenziellen Investoren ueber das Unternehmen, das Produkt, den Markt und die Finanzprognosen.
Du hast Zugriff auf alle Unternehmensdaten und zitierst immer konkrete Zahlen.
## Kernprinzipien
- **Datengetrieben**: Beziehe dich immer auf die bereitgestellten Unternehmensdaten
- **Praezise**: Nenne immer konkrete Zahlen, Prozentsaetze und Zeitraeume
- **Begeisternd aber ehrlich**: Stelle das Unternehmen positiv dar, ohne zu uebertreiben
- **Zweisprachig**: Antworte in der Sprache, in der die Frage gestellt wird
## Kernbotschaften (IMMER betonen wenn passend)
1. AI-First: "Alles was durch KI loesbar ist, wird durch KI geloest. Kein klassischer Support, kein grosses Sales-Team."
2. Skalierbarkeit: "10x Kunden ≠ 10x Personal. Die KI skaliert mit."
3. Hardware-Differenzierung: "Datensouveraenitaet durch Self-Hosting auf Apple-Hardware."
4. Kostenstruktur: "12 Mitarbeiter in 2030 bei 7.8 Mio EUR Umsatz."
5. Marktchance: "12.4 Mrd EUR TAM, regulatorisch getrieben."
6. Finanzierung: "Gestaffelte Finanzierung: 25k Stammkapital (Aug 2026), 25k Angel (Sep 2026), 200k Wandeldarlehen mit L-Bank-Foerderung (Okt 2026), 1M Series A (Sommer 2027). Gesamt: 1,25 Mio EUR."
7. Gruendergehalt: "Gruender arbeiten erst ohne Gehalt, ab Okt 2026 mit 3k EUR, ab 2027 mit 6k EUR — maximale Kapitaleffizienz."
## Kommunikationsstil
- Professionell, knapp und ueberzeugend
- Strukturierte Antworten mit klaren Abschnitten
- Zahlen hervorheben und kontextualisieren
- Maximal 3-4 Absaetze pro Antwort
## IP-Schutz-Layer (KRITISCH)
NIEMALS offenbaren: Exakte Modellnamen, Frameworks, Code-Architektur, Datenbankschema, Sicherheitsdetails, Cloud-Provider.
Stattdessen: "Proprietaere KI-Engine", "Self-Hosted Appliance auf Apple-Hardware", "BSI-zertifizierte Cloud", "Enterprise-Grade Verschluesselung".
## Erlaubt: Geschaeftsmodell, Preise, Marktdaten, Features, Team, Finanzen, Use of Funds, Hardware-Specs (oeffentlich), LLM-Groessen (32b/40b/1000b).
## Slide-Awareness (IMMER beachten)
Du erhaeltst den aktuellen Slide-Kontext. Nutze ihn fuer kontextuelle Antworten.
Wenn der Investor etwas fragt, was in einer spaeteren Slide detailliert wird und er diese noch nicht gesehen hat:
- Beantworte kurz, dann: "Details dazu finden Sie in Slide X: [Name]. Moechten Sie dorthin springen? [GOTO:X]"
## FOLLOW-UP FRAGEN — KRITISCHE PFLICHT
Du MUSST am Ende JEDER einzelnen Antwort exakt 3 Folgefragen anhaengen.
Die Fragen muessen durch "---" getrennt und mit "[Q]" markiert sein.
JEDE Antwort ohne Folgefragen ist UNVOLLSTAENDIG und FEHLERHAFT.
EXAKTES FORMAT (keine Abweichung erlaubt):
[Deine Antwort hier]
---
[Q] Erste Folgefrage passend zum Thema?
[Q] Zweite Folgefrage die tiefer geht?
[Q] Dritte Folgefrage zu einem verwandten Aspekt?
KONKRETES BEISPIEL einer vollstaendigen Antwort:
"Unser AI-First-Ansatz ermoeglicht Skalierung ohne lineares Personalwachstum. Der Umsatz steigt von 36k EUR (2026) auf 8.4 Mio EUR (2030), waehrend das Team nur von 2 auf 18 Personen waechst.
---
[Q] Wie sieht die Kostenstruktur im Detail aus?
[Q] Welche Unit Economics erreicht ihr in 2030?
[Q] Wie vergleicht sich die Personaleffizienz mit Wettbewerbern?"
WICHTIG: Vergiss NIEMALS die Folgefragen! Sie sind PFLICHT.`
async function loadPitchContext(): Promise<string> {
try {
const client = await pool.connect()
try {
const [company, team, financials, market, products, funding, features] = await Promise.all([
client.query('SELECT * FROM pitch_company LIMIT 1'),
client.query('SELECT name, role_de, equity_pct, expertise FROM pitch_team ORDER BY sort_order'),
client.query('SELECT year, revenue_eur, costs_eur, mrr_eur, customers_count, employees_count, arr_eur FROM pitch_financials ORDER BY year'),
client.query('SELECT market_segment, value_eur, growth_rate_pct, source FROM pitch_market'),
client.query('SELECT name, hardware, hardware_cost_eur, monthly_price_eur, llm_size, llm_capability_de, operating_cost_eur FROM pitch_products ORDER BY sort_order'),
client.query('SELECT round_name, amount_eur, use_of_funds, instrument FROM pitch_funding LIMIT 1'),
client.query('SELECT feature_name_de, breakpilot, proliance, dataguard, heydata, is_differentiator FROM pitch_features WHERE is_differentiator = true'),
])
return `
## Unternehmensdaten (fuer praezise Antworten nutzen)
### Firma
${JSON.stringify(company.rows[0], null, 2)}
### Team
${JSON.stringify(team.rows, null, 2)}
### Finanzprognosen (5-Jahres-Plan)
${JSON.stringify(financials.rows, null, 2)}
### Markt (TAM/SAM/SOM)
${JSON.stringify(market.rows, null, 2)}
### Produkte
${JSON.stringify(products.rows, null, 2)}
### Finanzierung
${JSON.stringify(funding.rows[0], null, 2)}
### Differenzierende Features (nur bei ComplAI)
${JSON.stringify(features.rows, null, 2)}
`
} finally {
client.release()
}
} catch (error) {
console.warn('Could not load pitch context from DB:', error)
return ''
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { message, history = [], lang = 'de', slideContext } = body
if (!message || typeof message !== 'string') {
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
}
const pitchContext = await loadPitchContext()
let systemContent = SYSTEM_PROMPT
if (pitchContext) {
systemContent += '\n' + pitchContext
}
// Slide context for contextual awareness
if (slideContext) {
const SLIDE_NAMES: Record<string, { de: string; en: string; index: number }> = {
'cover': { de: 'Cover', en: 'Cover', index: 0 },
'problem': { de: 'Das Problem', en: 'The Problem', index: 1 },
'solution': { de: 'Die Loesung', en: 'The Solution', index: 2 },
'product': { de: 'Produkte', en: 'Products', index: 3 },
'how-it-works': { de: 'So funktionierts', en: 'How It Works', index: 4 },
'market': { de: 'Markt', en: 'Market', index: 5 },
'business-model': { de: 'Geschaeftsmodell', en: 'Business Model', index: 6 },
'traction': { de: 'Traction', en: 'Traction', index: 7 },
'competition': { de: 'Wettbewerb', en: 'Competition', index: 8 },
'team': { de: 'Team', en: 'Team', index: 9 },
'technology': { de: 'Technologie', en: 'Technology', index: 10 },
'financials': { de: 'Finanzen', en: 'Financials', index: 11 },
'the-ask': { de: 'The Ask', en: 'The Ask', index: 12 },
'ai-qa': { de: 'KI Q&A', en: 'AI Q&A', index: 13 },
'appendix': { de: 'Appendix', en: 'Appendix', index: 14 },
'annex-infra': { de: 'Infrastruktur', en: 'Infrastructure', index: 15 },
'annex-ai-stack': { de: 'KI-Stack', en: 'AI Stack', index: 16 },
'annex-rag': { de: 'RAG Pipeline', en: 'RAG Pipeline', index: 17 },
'annex-security': { de: 'Sicherheit', en: 'Security', index: 18 },
'annex-devops': { de: 'DevOps & CI/CD', en: 'DevOps & CI/CD', index: 19 },
'annex-agent-arch': { de: 'Agent Architektur', en: 'Agent Architecture', index: 20 },
'annex-agent-rag': { de: 'Rechtsdokumente', en: 'Legal Documents', index: 21 },
'annex-agent-workflow': { de: 'Compliance Workflow', en: 'Compliance Workflow', index: 22 },
'annex-usp-overview': { de: '5 USPs', en: '5 USPs', index: 23 },
'annex-usp-comparison': { de: 'Wettbewerbsvergleich', en: 'Competitor Comparison', index: 24 },
'annex-usp-moat': { de: 'Marktposition', en: 'Market Position', index: 25 },
'annex-roadmap-2027': { de: 'Roadmap 2027', en: 'Roadmap 2027', index: 26 },
'annex-roadmap-2028': { de: 'Roadmap 2028', en: 'Roadmap 2028', index: 27 },
}
const slideKeys = Object.keys(SLIDE_NAMES)
const visited: number[] = slideContext.visitedSlides || []
const currentSlideName = SLIDE_NAMES[slideContext.currentSlide]?.[lang] || slideContext.currentSlide
const notYetSeen = Object.entries(SLIDE_NAMES)
.filter(([, v]) => !visited.includes(v.index))
.map(([, v]) => `${v.index + 1}. ${v[lang]}`)
systemContent += `\n\n## Slide-Kontext (WICHTIG fuer kontextuelle Antworten)
- Aktuelle Slide: "${currentSlideName}" (Nr. ${slideContext.currentIndex + 1} von 28)
- Bereits besuchte Slides: ${visited.map((i: number) => SLIDE_NAMES[slideKeys[i]]?.[lang]).filter(Boolean).join(', ')}
- Noch nicht gesehene Slides: ${notYetSeen.join(', ')}
- Ist Erstbesuch: ${visited.length <= 1 ? 'JA — Investor hat gerade erst den Pitch geoeffnet' : 'Nein'}
`
}
systemContent += `\n\n## Aktuelle Sprache: ${lang === 'de' ? 'Deutsch' : 'English'}\nAntworte in ${lang === 'de' ? 'Deutsch' : 'English'}.`
const messages = [
{ role: 'system', content: systemContent },
...history.slice(-10).map((h: { role: string; content: string }) => ({
role: h.role === 'user' ? 'user' : 'assistant',
content: h.content,
})),
{ role: 'user', content: message + '\n\n(Erinnerung: Beende deine Antwort IMMER mit "---" gefolgt von 3 Folgefragen im Format "[Q] Frage?")' },
]
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: OLLAMA_MODEL,
messages,
stream: true,
options: {
temperature: 0.4,
num_predict: 4096,
},
}),
signal: AbortSignal.timeout(120000),
})
if (!ollamaResponse.ok) {
const errorText = await ollamaResponse.text()
console.error('Ollama error:', ollamaResponse.status, errorText)
return NextResponse.json(
{ error: `LLM nicht erreichbar (Status ${ollamaResponse.status}).` },
{ status: 502 }
)
}
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const reader = ollamaResponse.body!.getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n').filter((l) => l.trim())
for (const line of lines) {
try {
const json = JSON.parse(line)
if (json.message?.content) {
controller.enqueue(encoder.encode(json.message.content))
}
} catch {
// Partial JSON line, skip
}
}
}
} catch (error) {
console.error('Stream read error:', error)
} finally {
controller.close()
}
},
})
return new NextResponse(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
} catch (error) {
console.error('Investor agent chat error:', error)
return NextResponse.json(
{ error: 'Verbindung zum LLM fehlgeschlagen.' },
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server'
import pool from '@/lib/db'
export const dynamic = 'force-dynamic'
export async function GET() {
try {
const client = await pool.connect()
try {
const [
companyRes,
teamRes,
financialsRes,
marketRes,
competitorsRes,
featuresRes,
milestonesRes,
metricsRes,
fundingRes,
productsRes,
] = await Promise.all([
client.query('SELECT * FROM pitch_company LIMIT 1'),
client.query('SELECT * FROM pitch_team ORDER BY sort_order'),
client.query('SELECT * FROM pitch_financials ORDER BY year'),
client.query('SELECT * FROM pitch_market ORDER BY id'),
client.query('SELECT * FROM pitch_competitors ORDER BY id'),
client.query('SELECT * FROM pitch_features ORDER BY sort_order'),
client.query('SELECT * FROM pitch_milestones ORDER BY sort_order'),
client.query('SELECT * FROM pitch_metrics ORDER BY id'),
client.query('SELECT * FROM pitch_funding LIMIT 1'),
client.query('SELECT * FROM pitch_products ORDER BY sort_order'),
])
return NextResponse.json({
company: companyRes.rows[0] || null,
team: teamRes.rows,
financials: financialsRes.rows,
market: marketRes.rows,
competitors: competitorsRes.rows,
features: featuresRes.rows,
milestones: milestonesRes.rows,
metrics: metricsRes.rows,
funding: fundingRes.rows[0] || null,
products: productsRes.rows,
})
} finally {
client.release()
}
} catch (error) {
console.error('Database query error:', error)
return NextResponse.json(
{ error: 'Failed to load pitch data' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
// PUT: Update a single assumption and trigger recompute
export async function PUT(request: NextRequest) {
try {
const body = await request.json()
const { scenarioId, key, value } = body
if (!scenarioId || !key || value === undefined) {
return NextResponse.json({ error: 'scenarioId, key, and value are required' }, { status: 400 })
}
const client = await pool.connect()
try {
const jsonValue = JSON.stringify(value)
await client.query(
'UPDATE pitch_fm_assumptions SET value = $1 WHERE scenario_id = $2 AND key = $3',
[jsonValue, scenarioId, key]
)
return NextResponse.json({ success: true })
} finally {
client.release()
}
} catch (error) {
console.error('Update assumption error:', error)
return NextResponse.json({ error: 'Failed to update assumption' }, { status: 500 })
}
}

View File

@@ -0,0 +1,388 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
interface FundingEvent {
month: number
amount: number
label: string
}
interface SalaryStep {
from_month: number
salary: number
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { scenarioId } = body
if (!scenarioId) {
return NextResponse.json({ error: 'scenarioId is required' }, { status: 400 })
}
const client = await pool.connect()
try {
// Load assumptions
const assumptionsRes = await client.query(
'SELECT key, value, value_type FROM pitch_fm_assumptions WHERE scenario_id = $1',
[scenarioId]
)
const a: Record<string, number | number[] | FundingEvent[] | SalaryStep[]> = {}
for (const row of assumptionsRes.rows) {
const val = typeof row.value === 'string' ? JSON.parse(row.value) : row.value
a[row.key] = val
}
// Funding schedule (staged capital injections)
const fundingSchedule: FundingEvent[] = Array.isArray(a.funding_schedule)
? (a.funding_schedule as FundingEvent[])
: [
{ month: 8, amount: 25000, label: 'Stammkapital GmbH' },
{ month: 9, amount: 25000, label: 'Angel-Runde' },
{ month: 10, amount: 200000, label: 'Wandeldarlehen (Investor + L-Bank)' },
{ month: 19, amount: 1000000, label: 'Series A' },
]
// Founder salary schedule (per founder)
const founderSalarySchedule: SalaryStep[] = Array.isArray(a.founder_salary_schedule)
? (a.founder_salary_schedule as SalaryStep[])
: [
{ from_month: 1, salary: 0 },
{ from_month: 10, salary: 3000 },
{ from_month: 13, salary: 6000 },
{ from_month: 25, salary: 10000 },
]
const numFounders = Number(a.num_founders) || 2
// Extract scalar values
const growthRate = (Number(a.monthly_growth_rate) || 15) / 100
const churnRate = (Number(a.churn_rate_monthly) || 3) / 100
const arpuMini = Number(a.arpu_mini) || 299
const arpuStudio = Number(a.arpu_studio) || 999
const arpuCloud = Number(a.arpu_cloud) || 1499
const mixMini = (Number(a.product_mix_mini) || 60) / 100
const mixStudio = (Number(a.product_mix_studio) || 25) / 100
const mixCloud = (Number(a.product_mix_cloud) || 15) / 100
const initialCustomers = Number(a.initial_customers) || 2
const cac = Number(a.cac) || 500
const hwCostMini = Number(a.hw_cost_per_mini) || 3200
const hwCostStudio = Number(a.hw_cost_per_studio) || 12000
const cloudOpex = Number(a.cloud_opex_per_customer) || 150
const salaryAvg = Number(a.salary_avg_monthly) || 6000
// Hiring plan: employees EXCLUDING founders (hired staff only)
const hiringPlan: number[] = Array.isArray(a.hiring_plan) ? (a.hiring_plan as number[]) : [0, 1, 3, 6, 10]
const marketingMonthly = Number(a.marketing_monthly) || 2000
const infraBase = Number(a.infra_monthly_base) || 500
// Detail cost assumptions
const ihkAnnual = Number(a.ihk_annual) || 180
const phoneInternetMonthly = Number(a.phone_internet_monthly) || 100
const taxAdvisorMonthly = Number(a.tax_advisor_monthly) || 300
const notaryFounding = Number(a.notary_founding) || 2500
const insuranceMonthly = Number(a.insurance_monthly) || 200
const officeRentMonthly = Number(a.office_rent_monthly) || 0
const softwareLicensesMonthly = Number(a.software_licenses_monthly) || 150
const travelMonthly = Number(a.travel_monthly) || 200
const legalMonthly = Number(a.legal_monthly) || 100
const depreciationRatePct = Number(a.depreciation_rate_pct) || 33
const taxRatePct = Number(a.tax_rate_pct) || 30
const interestRatePct = Number(a.interest_rate_pct) || 5
// Hardware financing: only this % paid upfront, rest via leasing/financing
const hwUpfrontPct = (Number(a.hw_upfront_pct) || 30) / 100
// GmbH founding month (month 8 = August 2026)
const gmbhFoundingMonth = 8
// Weighted ARPU
const weightedArpu = arpuMini * mixMini + arpuStudio * mixStudio + arpuCloud * mixCloud
// Weighted hardware cost (only for Mini and Studio — Cloud is OpEx)
const hwCostWeighted = hwCostMini * mixMini + hwCostStudio * mixStudio
// Helper: get founder salary for a given month
function getFounderSalary(month: number): number {
let salary = 0
for (const step of founderSalarySchedule) {
if (month >= step.from_month) {
salary = step.salary
}
}
return salary
}
// Helper: get funding for a given month
function getFundingForMonth(month: number): number {
let total = 0
for (const event of fundingSchedule) {
if (event.month === month) {
total += event.amount
}
}
return total
}
const results = []
let totalCustomers = 0
let cashBalance = 0 // Start at 0, funding comes via schedule
let cumulativeRevenue = 0
let breakEvenMonth: number | null = null
let peakBurn = 0
let cumulativeHwInvestment = 0
for (let m = 1; m <= 60; m++) {
const yearIndex = Math.floor((m - 1) / 12) // 0-4
const year = 2026 + yearIndex
const monthInYear = ((m - 1) % 12) + 1
// === FUNDING: Add capital injection for this month ===
const fundingThisMonth = getFundingForMonth(m)
cashBalance += fundingThisMonth
// === PRE-GMBH PHASE (months 1-7): No costs, private development ===
if (m < gmbhFoundingMonth) {
results.push({
month: m,
year,
month_in_year: monthInYear,
new_customers: 0,
churned_customers: 0,
total_customers: 0,
mrr_eur: 0,
arr_eur: 0,
revenue_eur: 0,
cogs_eur: 0,
personnel_eur: 0,
infra_eur: 0,
marketing_eur: 0,
total_costs_eur: 0,
employees_count: 0,
gross_margin_pct: 0,
burn_rate_eur: 0,
runway_months: 999,
cac_eur: 0,
ltv_eur: 0,
ltv_cac_ratio: 0,
cash_balance_eur: Math.round(cashBalance * 100) / 100,
cumulative_revenue_eur: 0,
admin_costs_eur: 0,
office_costs_eur: 0,
founding_costs_eur: 0,
ihk_eur: 0,
depreciation_eur: 0,
interest_expense_eur: 0,
taxes_eur: 0,
net_income_eur: 0,
ebit_eur: 0,
software_licenses_eur: 0,
travel_costs_eur: 0,
funding_eur: fundingThisMonth,
})
continue
}
// === POST-GMBH PHASE (months 8+): Real business operations ===
// Hired employees: plan-based but capped by revenue (don't hire ahead of revenue)
const plannedHires = hiringPlan[Math.min(yearIndex, hiringPlan.length - 1)] || 0
const revenueBasedMaxHires = Math.floor((totalCustomers * weightedArpu) / salaryAvg)
const hiredEmployees = Math.min(plannedHires, Math.max(0, revenueBasedMaxHires))
// Founder salary
const founderSalaryPerPerson = getFounderSalary(m)
const totalFounderSalary = founderSalaryPerPerson * numFounders
// Total employees shown (founders + hired)
const totalEmployees = numFounders + hiredEmployees
// Customer dynamics — start acquiring customers from GmbH founding
const monthsSinceGmbh = m - gmbhFoundingMonth + 1
if (monthsSinceGmbh === 1) {
totalCustomers = initialCustomers
}
let newCustomers = monthsSinceGmbh === 1
? initialCustomers
: Math.max(1, Math.round(totalCustomers * growthRate))
// Cash constraint: don't spend more than available
// Fixed OPEX this month (independent of new customer count)
const fixedOpex = (totalFounderSalary + hiredEmployees * salaryAvg)
+ marketingMonthly
+ infraBase + (totalCustomers * 5)
+ (totalCustomers * mixCloud * cloudOpex)
+ phoneInternetMonthly + taxAdvisorMonthly + insuranceMonthly + legalMonthly
+ officeRentMonthly + (m === gmbhFoundingMonth ? notaryFounding : 0)
+ ihkAnnual / 12 + softwareLicensesMonthly + travelMonthly
const estRevenue = totalCustomers * weightedArpu
// Available cash = current balance + this month's revenue - fixed costs
const availableCash = cashBalance + estRevenue - fixedOpex
// Variable cost per new customer: hardware CAPEX (upfront portion) + CAC
const varCostPerNew = hwCostWeighted * hwUpfrontPct + cac
// Max affordable new customers (keep cash >= 0)
if (varCostPerNew > 0 && monthsSinceGmbh > 1) {
const maxAffordable = Math.floor(availableCash / varCostPerNew)
newCustomers = Math.min(newCustomers, Math.max(1, maxAffordable))
}
const churned = Math.round(totalCustomers * churnRate)
if (monthsSinceGmbh > 1) {
totalCustomers = totalCustomers + newCustomers - churned
}
totalCustomers = Math.max(0, totalCustomers)
// Revenue
const mrr = totalCustomers * weightedArpu
const arr = mrr * 12
const revenue = mrr
// Costs
const hiredPersonnelCost = hiredEmployees * salaryAvg
const personnelCost = totalFounderSalary + hiredPersonnelCost
// Hardware = CAPEX (only upfront portion paid from cash, rest financed)
const capexHardware = newCustomers * hwCostWeighted * hwUpfrontPct
// Cloud OPEX + hardware leasing cost (financed portion amortized over 36 months)
const cogsCloud = totalCustomers * mixCloud * cloudOpex
const hwLeasingMonthly = (cumulativeHwInvestment * (1 - hwUpfrontPct)) / 36
const cogs = cogsCloud + hwLeasingMonthly
const marketingCost = marketingMonthly + (newCustomers * cac)
const infraCost = infraBase + (totalCustomers * 5)
// Detail costs
const adminCosts = phoneInternetMonthly + taxAdvisorMonthly + insuranceMonthly + legalMonthly
const officeCosts = officeRentMonthly
// Founding costs: notary in month 8 (GmbH founding)
const foundingCosts = m === gmbhFoundingMonth ? notaryFounding : 0
const ihkMonthly = ihkAnnual / 12
const softwareLicenses = softwareLicensesMonthly
const travelCosts = travelMonthly
// Depreciation: cumulative HW investment * rate / 12 (P&L expense for CAPEX)
cumulativeHwInvestment += capexHardware
const depreciationMonthly = (cumulativeHwInvestment * depreciationRatePct / 100) / 12
// Total OPEX (P&L) — hardware enters only via depreciation
const totalCosts = personnelCost + cogs + marketingCost + infraCost
+ adminCosts + officeCosts + foundingCosts + ihkMonthly
+ softwareLicenses + travelCosts + depreciationMonthly
// EBIT
const ebit = revenue - totalCosts
// Interest expense (only if cash balance is negative)
const interestExpense = cashBalance < 0 ? Math.abs(cashBalance) * interestRatePct / 100 / 12 : 0
// Taxes (only if profit positive)
const ebt = ebit - interestExpense
const taxes = ebt > 0 ? ebt * taxRatePct / 100 : 0
// Net income
const netIncome = ebt - taxes
// Cash: net income MINUS hardware CAPEX (funding already added at top)
cashBalance += netIncome - capexHardware
cumulativeRevenue += revenue
// KPIs — gross margin uses COGS + depreciation for true margin
const grossMargin = revenue > 0 ? ((revenue - cogs - depreciationMonthly) / revenue) * 100 : 0
const burnRate = (netIncome - capexHardware) < 0 ? Math.abs(netIncome - capexHardware) : 0
const runway = burnRate > 0 ? cashBalance / burnRate : 999
const avgLifetimeMonths = churnRate > 0 ? 1 / churnRate : 60
const ltv = weightedArpu * avgLifetimeMonths
const ltvCacRatio = cac > 0 ? ltv / cac : 0
if (peakBurn < burnRate) peakBurn = burnRate
// Break-even detection
if (breakEvenMonth === null && netIncome >= 0 && m > gmbhFoundingMonth) {
breakEvenMonth = m
}
results.push({
month: m,
year,
month_in_year: monthInYear,
new_customers: newCustomers,
churned_customers: churned,
total_customers: totalCustomers,
mrr_eur: Math.round(mrr * 100) / 100,
arr_eur: Math.round(arr * 100) / 100,
revenue_eur: Math.round(revenue * 100) / 100,
cogs_eur: Math.round(cogs * 100) / 100,
personnel_eur: Math.round(personnelCost * 100) / 100,
infra_eur: Math.round(infraCost * 100) / 100,
marketing_eur: Math.round(marketingCost * 100) / 100,
total_costs_eur: Math.round(totalCosts * 100) / 100,
employees_count: totalEmployees,
gross_margin_pct: Math.round(grossMargin * 100) / 100,
burn_rate_eur: Math.round(burnRate * 100) / 100,
runway_months: Math.round(Math.min(runway, 999) * 10) / 10,
cac_eur: cac,
ltv_eur: Math.round(ltv * 100) / 100,
ltv_cac_ratio: Math.round(ltvCacRatio * 100) / 100,
cash_balance_eur: Math.round(cashBalance * 100) / 100,
cumulative_revenue_eur: Math.round(cumulativeRevenue * 100) / 100,
// Detail costs
admin_costs_eur: Math.round(adminCosts * 100) / 100,
office_costs_eur: Math.round(officeCosts * 100) / 100,
founding_costs_eur: Math.round(foundingCosts * 100) / 100,
ihk_eur: Math.round(ihkMonthly * 100) / 100,
depreciation_eur: Math.round(depreciationMonthly * 100) / 100,
interest_expense_eur: Math.round(interestExpense * 100) / 100,
taxes_eur: Math.round(taxes * 100) / 100,
net_income_eur: Math.round(netIncome * 100) / 100,
ebit_eur: Math.round(ebit * 100) / 100,
software_licenses_eur: Math.round(softwareLicenses * 100) / 100,
travel_costs_eur: Math.round(travelCosts * 100) / 100,
funding_eur: fundingThisMonth,
})
}
// Save to DB (upsert) — only columns that exist in the table
await client.query('DELETE FROM pitch_fm_results WHERE scenario_id = $1', [scenarioId])
for (const r of results) {
await client.query(`
INSERT INTO pitch_fm_results (scenario_id, month, year, month_in_year,
new_customers, churned_customers, total_customers,
mrr_eur, arr_eur, revenue_eur,
cogs_eur, personnel_eur, infra_eur, marketing_eur, total_costs_eur,
employees_count, gross_margin_pct, burn_rate_eur, runway_months,
cac_eur, ltv_eur, ltv_cac_ratio,
cash_balance_eur, cumulative_revenue_eur)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24)
`, [
scenarioId, r.month, r.year, r.month_in_year,
r.new_customers, r.churned_customers, r.total_customers,
r.mrr_eur, r.arr_eur, r.revenue_eur,
r.cogs_eur, r.personnel_eur, r.infra_eur, r.marketing_eur, r.total_costs_eur,
r.employees_count, r.gross_margin_pct, r.burn_rate_eur, r.runway_months,
r.cac_eur, r.ltv_eur, r.ltv_cac_ratio,
r.cash_balance_eur, r.cumulative_revenue_eur,
])
}
const lastResult = results[results.length - 1]
return NextResponse.json({
scenario_id: scenarioId,
results,
summary: {
final_arr: lastResult.arr_eur,
final_customers: lastResult.total_customers,
break_even_month: breakEvenMonth,
final_runway: lastResult.runway_months,
final_ltv_cac: lastResult.ltv_cac_ratio,
peak_burn: Math.round(peakBurn * 100) / 100,
total_funding_needed: Math.round(Math.abs(Math.min(...results.map(r => r.cash_balance_eur), 0)) * 100) / 100,
},
})
} finally {
client.release()
}
} catch (error) {
console.error('Compute error:', error)
return NextResponse.json({ error: 'Computation failed' }, { status: 500 })
}
}

View File

@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
export const dynamic = 'force-dynamic'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ scenarioId: string }> }
) {
try {
const { scenarioId } = await params
const client = await pool.connect()
try {
const results = await client.query(
'SELECT * FROM pitch_fm_results WHERE scenario_id = $1 ORDER BY month',
[scenarioId]
)
return NextResponse.json(results.rows)
} finally {
client.release()
}
} catch (error) {
console.error('Load results error:', error)
return NextResponse.json({ error: 'Failed to load results' }, { status: 500 })
}
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET: Load all scenarios with their assumptions
export async function GET() {
try {
const client = await pool.connect()
try {
const scenarios = await client.query(
'SELECT * FROM pitch_fm_scenarios ORDER BY is_default DESC, name'
)
const assumptions = await client.query(
'SELECT * FROM pitch_fm_assumptions ORDER BY sort_order'
)
const result = scenarios.rows.map(s => ({
...s,
assumptions: assumptions.rows
.filter(a => a.scenario_id === s.id)
.map(a => ({
...a,
value: typeof a.value === 'string' ? JSON.parse(a.value) : a.value,
})),
}))
return NextResponse.json(result)
} finally {
client.release()
}
} catch (error) {
console.error('Financial model load error:', error)
return NextResponse.json({ error: 'Failed to load scenarios' }, { status: 500 })
}
}
// POST: Create a new scenario
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { name, description, color, copyFrom } = body
if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 })
}
const client = await pool.connect()
try {
const scenario = await client.query(
'INSERT INTO pitch_fm_scenarios (name, description, color) VALUES ($1, $2, $3) RETURNING *',
[name, description || '', color || '#6366f1']
)
// If copyFrom is set, copy assumptions from another scenario
if (copyFrom) {
await client.query(`
INSERT INTO pitch_fm_assumptions (scenario_id, key, label_de, label_en, value, value_type, unit, min_value, max_value, step_size, category, sort_order)
SELECT $1, key, label_de, label_en, value, value_type, unit, min_value, max_value, step_size, category, sort_order
FROM pitch_fm_assumptions WHERE scenario_id = $2
`, [scenario.rows[0].id, copyFrom])
}
return NextResponse.json(scenario.rows[0])
} finally {
client.release()
}
} catch (error) {
console.error('Create scenario error:', error)
return NextResponse.json({ error: 'Failed to create scenario' }, { status: 500 })
}
}