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:
263
pitch-deck/app/api/chat/route.ts
Normal file
263
pitch-deck/app/api/chat/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
57
pitch-deck/app/api/data/route.ts
Normal file
57
pitch-deck/app/api/data/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
30
pitch-deck/app/api/financial-model/assumptions/route.ts
Normal file
30
pitch-deck/app/api/financial-model/assumptions/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
388
pitch-deck/app/api/financial-model/compute/route.ts
Normal file
388
pitch-deck/app/api/financial-model/compute/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
73
pitch-deck/app/api/financial-model/route.ts
Normal file
73
pitch-deck/app/api/financial-model/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user