Merge remote-tracking branch 'gitea/main'
Some checks failed
Build pitch-deck / build-push-deploy (push) Failing after 1m13s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 49s
CI / test-python-voice (push) Successful in 38s
CI / test-bqas (push) Successful in 31s

# Conflicts:
#	pitch-deck/components/slides/MilestonesSlide.tsx
#	pitch-deck/lib/finanzplan/engine.ts
This commit is contained in:
Benjamin Admin
2026-04-27 13:14:54 +02:00
21 changed files with 624 additions and 354 deletions

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db' import pool from '@/lib/db'
import { getSessionFromCookie } from '@/lib/auth'
import { SLIDE_ORDER } from '@/lib/slide-order' import { SLIDE_ORDER } from '@/lib/slide-order'
const LITELLM_URL = process.env.LITELLM_URL || 'https://llm-dev.meghsakha.com' const LITELLM_URL = process.env.LITELLM_URL || 'https://llm-dev.meghsakha.com'
@@ -34,7 +35,8 @@ const SLIDE_DISPLAY_NAMES: Record<string, { de: string; en: string }> = {
const slideCount = SLIDE_ORDER.length const slideCount = SLIDE_ORDER.length
const SYSTEM_PROMPT = `# Investor Agent — BreakPilot ComplAI // Static prefix: Identität through Kernbotschaft #8 — #9 and VERSIONS-ISOLATION injected at runtime
const SYSTEM_PROMPT_PART1 = `# Investor Agent — BreakPilot ComplAI
## Identität ## Identität
Du bist der BreakPilot ComplAI Investor Relations Agent. Du beantwortest Fragen von Du bist der BreakPilot ComplAI Investor Relations Agent. Du beantwortest Fragen von
@@ -55,8 +57,10 @@ Du hast Zugriff auf alle Unternehmensdaten und zitierst immer konkrete Zahlen.
5. EU-Infrastruktur: "BSI-zertifizierte Cloud in Deutschland oder Frankreich. 100% Datensouveränität. KEINE US-Anbieter. Isolierte Namespaces." 5. EU-Infrastruktur: "BSI-zertifizierte Cloud in Deutschland oder Frankreich. 100% Datensouveränität. KEINE US-Anbieter. Isolierte Namespaces."
6. Zielgruppen: "Maschinen- und Anlagenbauer, Automobilindustrie, Zulieferer und alle produzierenden Unternehmen." 6. Zielgruppen: "Maschinen- und Anlagenbauer, Automobilindustrie, Zulieferer und alle produzierenden Unternehmen."
7. Geschäftsmodell: "SaaS, mitarbeiterbasiertes Pricing. Drei Tiers: Starter (3.600 EUR/Jahr), Professional (15.000-40.000 EUR/Jahr), Enterprise (ab 50.000 EUR/Jahr). Plus Beratung & Service (10.000-30.000 EUR/Monat). Kunden sparen mehr als sie zahlen — ROI ab Tag 1." 7. Geschäftsmodell: "SaaS, mitarbeiterbasiertes Pricing. Drei Tiers: Starter (3.600 EUR/Jahr), Professional (15.000-40.000 EUR/Jahr), Enterprise (ab 50.000 EUR/Jahr). Plus Beratung & Service (10.000-30.000 EUR/Monat). Kunden sparen mehr als sie zahlen — ROI ab Tag 1."
8. Team: "Lean-Team: 2 Gründer + 7 Mitarbeiter bis 2030 (9 Personen gesamt). Erste Einstellung: IT-Recht/Datenschutzjurist (50%). Dann: Security Engineer, Vertrieb, Backend, Kundenbetreuer, Marketing, DevOps. Jede Einstellung an konkreten Umsatzmeilenstein gekoppelt." 8. Team: "Lean-Team: 2 Gründer + 7 Mitarbeiter bis 2030 (9 Personen gesamt). Erste Einstellung: IT-Recht/Datenschutzjurist (50%). Dann: Security Engineer, Vertrieb, Backend, Kundenbetreuer, Marketing, DevOps. Jede Einstellung an konkreten Umsatzmeilenstein gekoppelt."`
9. Finanzplan: "Gründung August 2026. Pre-Seed über Wandeldarlehen (200.000 EUR: 40.000 Investor + 160.000 L-Bank). ~195 Kunden und ~3,3 Mio. Umsatz bis 2030. 9 Mitarbeiter. Optionale 2. Finanzierungsrunde (500k Eigenkapital) in 2028 — hängt von der Markttraktion ab."
// Static middle: Kommunikationsstil — injected between #9 and VERSIONS-ISOLATION
const SYSTEM_PROMPT_PART2 = `
## Kommunikationsstil ## Kommunikationsstil
- Antworte IMMER wie ein Mensch in einem persönlichen Gespräch — ausformulierte Sätze, natürlicher Redefluss - Antworte IMMER wie ein Mensch in einem persönlichen Gespräch — ausformulierte Sätze, natürlicher Redefluss
@@ -65,14 +69,10 @@ Du hast Zugriff auf alle Unternehmensdaten und zitierst immer konkrete Zahlen.
- Nutze Übergangssätze wie "Der Grund dafür ist...", "Das haben wir bewusst so entschieden, weil...", "Besonders wichtig ist dabei..." - Nutze Übergangssätze wie "Der Grund dafür ist...", "Das haben wir bewusst so entschieden, weil...", "Besonders wichtig ist dabei..."
- Zahlen und Fakten natürlich in den Text einbetten, nicht als Liste aufreihen - Zahlen und Fakten natürlich in den Text einbetten, nicht als Liste aufreihen
- 3-5 Absätze pro Antwort, jeder Absatz ein eigenständiger Gedanke - 3-5 Absätze pro Antwort, jeder Absatz ein eigenständiger Gedanke
- Der Text muss sich gut anhören wenn er vorgelesen wird (TTS-optimiert) - Der Text muss sich gut anhören wenn er vorgelesen wird (TTS-optimiert)`
## VERSIONS-ISOLATION (ABSOLUT KRITISCH) // Static suffix: everything after VERSIONS-ISOLATION
- Du kennst NUR die Wandeldarlehen-Version mit 200.000 EUR Finanzierung. const SYSTEM_PROMPT_PART3 = `
- Es gibt KEINE andere Version. Es gibt KEINE 1-Mio-Version.
- Wenn nach anderen Versionen, anderen Investoren oder anderen Pitch Decks gefragt wird: "Dieses Pitch Deck wurde individuell für Sie erstellt. Es gibt nur diese Version."
- NIEMALS erwähnen: andere Finanzierungssummen, andere Bewertungen, andere Cap Tables.
- Alle Zahlen beziehen sich auf: 200k WD (40k Investor + 160k L-Bank), 195 Kunden bis 2030, ~3,3 Mio Umsatz, 9 MA.
## IP-Schutz-Layer (KRITISCH) ## IP-Schutz-Layer (KRITISCH)
NIEMALS offenbaren: Exakte Modellnamen, Frameworks, Code-Architektur, Datenbankschema, Sicherheitsdetails, Cloud-Provider. NIEMALS offenbaren: Exakte Modellnamen, Frameworks, Code-Architektur, Datenbankschema, Sicherheitsdetails, Cloud-Provider.
@@ -106,7 +106,7 @@ EXAKTES FORMAT (keine Abweichung erlaubt):
KONKRETES BEISPIEL einer vollständigen Antwort: KONKRETES BEISPIEL einer vollständigen Antwort:
"Unser AI-First-Ansatz ermöglicht Skalierung ohne lineares Personalwachstum. Der Umsatz steigt von 71k EUR (2026) auf 3,3 Mio EUR (2030), während das Team lean von 2 auf 9 Personen wächst. "Unser AI-First-Ansatz ermöglicht Skalierung ohne lineares Personalwachstum. Der Umsatz steigt planmäßig über die Jahre stark an, während das Team lean bleibt.
--- ---
[Q] Wie sieht die Kostenstruktur im Detail aus? [Q] Wie sieht die Kostenstruktur im Detail aus?
@@ -115,8 +115,125 @@ KONKRETES BEISPIEL einer vollständigen Antwort:
WICHTIG: Vergiss NIEMALS die Folgefragen! Sie sind PFLICHT.` WICHTIG: Vergiss NIEMALS die Folgefragen! Sie sind PFLICHT.`
async function loadPitchContext(): Promise<string> { async function loadFpLiquiditaetSummary(scenarioName: string): Promise<string> {
try { try {
const { rows } = await pool.query(
`SELECT l.row_label, l.values
FROM fp_liquiditaet l
JOIN fp_scenarios s ON s.id = l.scenario_id
WHERE s.name = $1
AND l.row_label IN ('LIQUIDITÄT', 'LIQUIDITAET', 'Summe ERTRÄGE', 'Summe EINZAHLUNGEN', 'Summe AUSZAHLUNGEN', 'ÜBERSCHUSS')
ORDER BY l.sort_order`,
[scenarioName]
)
if (rows.length === 0) {
if (scenarioName) console.warn('[chat] loadFpLiquiditaetSummary: no rows for scenario', JSON.stringify(scenarioName))
return ''
}
const years = [2026, 2027, 2028, 2029, 2030]
const summary: Record<string, Record<number, number>> = {}
for (const row of rows) {
const label = row.row_label === 'LIQUIDITAET' ? 'LIQUIDITÄT' : row.row_label
summary[label] = {}
for (let yi = 0; yi < years.length; yi++) {
const start = yi * 12 + 1
const end = start + 11
// LIQUIDITÄT is a balance (end-of-period): use December value
if (label === 'LIQUIDITÄT') {
summary[label][years[yi]] = Math.round(row.values[`m${end}`] || 0)
} else {
let s = 0
for (let m = start; m <= end; m++) s += row.values[`m${m}`] || 0
summary[label][years[yi]] = Math.round(s)
}
}
}
const lines = [`### Finanzplan-Liquidität (Szenario: ${scenarioName})`]
for (const [label, yearVals] of Object.entries(summary)) {
const vals = years.map(y => `${y}: ${(yearVals[y] || 0).toLocaleString('de-DE')} EUR`).join(' | ')
lines.push(`${label}: ${vals}`)
}
return lines.join('\n')
} catch {
return ''
}
}
interface VersionMeta {
versionName: string
scenarioName: string
fundingAmount: number
fundingInstrument: string
customers2030: number
revenue2030: number
employees2030: number
}
interface PitchContextResult {
contextString: string
meta: VersionMeta
}
const DEFAULT_META: VersionMeta = {
versionName: '', scenarioName: '', fundingAmount: 0,
fundingInstrument: 'Wandeldarlehen', customers2030: 0, revenue2030: 0, employees2030: 0,
}
function extractMeta(
versionName: string,
fmScenarios: Array<{ name: string }> | undefined,
funding: Record<string, unknown> | null,
financials: Array<Record<string, unknown>>
): VersionMeta {
const fin2030 = financials.find(f => Number(f.year) === 2030) ?? {}
return {
versionName,
scenarioName: fmScenarios?.[0]?.name ?? versionName,
fundingAmount: Number(funding?.amount_eur ?? 0),
fundingInstrument: String(funding?.instrument ?? 'Wandeldarlehen'),
customers2030: Number(fin2030.customers_count ?? 0),
revenue2030: Number(fin2030.revenue_eur ?? 0),
employees2030: Number(fin2030.employees_count ?? 0),
}
}
async function loadPitchContext(versionId?: string | null): Promise<PitchContextResult> {
try {
// Version-specific data path
if (versionId) {
const [vDataRes, vNameRes] = await Promise.all([
pool.query(`SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`, [versionId]),
pool.query(`SELECT name FROM pitch_versions WHERE id = $1`, [versionId]),
])
const map: Record<string, unknown> = {}
for (const r of vDataRes.rows) {
map[r.table_name] = typeof r.data === 'string' ? JSON.parse(r.data) : r.data
}
const company = (map.company as unknown[])?.[0] ?? null
const team = (map.team as unknown[]) ?? []
const financials = (map.financials as Array<Record<string, unknown>>) ?? []
const market = (map.market as unknown[]) ?? []
const products = (map.products as unknown[]) ?? []
const funding = ((map.funding as unknown[])?.[0] as Record<string, unknown>) ?? null
const features = ((map.features as Array<Record<string, unknown>>) ?? [])
.filter(f => f.is_differentiator)
const fmScenarios = map.fm_scenarios as Array<{ name: string }> | undefined
const versionName = vNameRes.rows[0]?.name ?? ''
const meta = extractMeta(versionName, fmScenarios, funding, financials)
const fpSummary = meta.scenarioName ? await loadFpLiquiditaetSummary(meta.scenarioName) : ''
return {
contextString: buildContextString(company, team, financials, market, products, funding, features, fpSummary),
meta,
}
}
// Fallback: base tables
const client = await pool.connect() const client = await pool.connect()
try { try {
const [company, team, financials, market, products, funding, features] = await Promise.all([ const [company, team, financials, market, products, funding, features] = await Promise.all([
@@ -128,59 +245,117 @@ async function loadPitchContext(): Promise<string> {
client.query('SELECT round_name, amount_eur, use_of_funds, instrument FROM pitch_funding LIMIT 1'), 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'), client.query('SELECT feature_name_de, breakpilot, proliance, dataguard, heydata, is_differentiator FROM pitch_features WHERE is_differentiator = true'),
]) ])
const meta = extractMeta('', undefined, funding.rows[0] ?? null, financials.rows)
return ` return {
## Unternehmensdaten (für präzise Antworten nutzen) contextString: buildContextString(
company.rows[0], team.rows, financials.rows, market.rows,
### Firma products.rows, funding.rows[0], features.rows, ''
${JSON.stringify(company.rows[0], null, 2)} ),
meta,
### 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 { } finally {
client.release() client.release()
} }
} catch (error) { } catch (error) {
console.warn('Could not load pitch context from DB:', error) console.warn('Could not load pitch context from DB:', error)
return '' return { contextString: '', meta: DEFAULT_META }
} }
} }
function buildContextString(
company: unknown, team: unknown, financials: unknown, market: unknown,
products: unknown, funding: unknown, features: unknown, fpSummary: string
): string {
return `
## Unternehmensdaten (für präzise Antworten nutzen)
### Firma
${JSON.stringify(company, null, 2)}
### Team
${JSON.stringify(team, null, 2)}
### Finanzprognosen (5-Jahres-Plan)
${JSON.stringify(financials, null, 2)}
### Markt (TAM/SAM/SOM)
${JSON.stringify(market, null, 2)}
### Produkte
${JSON.stringify(products, null, 2)}
### Finanzierung
${JSON.stringify(funding, null, 2)}
### Differenzierende Features (nur bei ComplAI)
${JSON.stringify(features, null, 2)}
${fpSummary ? '\n' + fpSummary : ''}
`
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const { message, history = [], lang = 'de', slideContext, faqContext } = body const { message, history = [], lang: langParam = 'de', slideContext, faqContext } = body
const lang: 'de' | 'en' = langParam === 'en' ? 'en' : 'de'
if (!message || typeof message !== 'string') { if (!message || typeof message !== 'string') {
return NextResponse.json({ error: 'Message is required' }, { status: 400 }) return NextResponse.json({ error: 'Message is required' }, { status: 400 })
} }
const pitchContext = await loadPitchContext() // Resolve investor's assigned version so the AI sees the correct scenario data
let versionId: string | null = null
try {
const session = await getSessionFromCookie()
if (session?.sub) {
const inv = await pool.query(
`SELECT assigned_version_id FROM pitch_investors WHERE id = $1`,
[session.sub]
)
versionId = inv.rows[0]?.assigned_version_id ?? null
}
} catch {
// Non-fatal: fall back to base tables
}
let systemContent = SYSTEM_PROMPT const { contextString, meta } = await loadPitchContext(versionId)
if (pitchContext) {
systemContent += '\n' + pitchContext // Build dynamic VERSIONS-ISOLATION and Kernbotschaft #9 from actual version data
const fmt = (n: number) => n.toLocaleString('de-DE')
const revM = meta.revenue2030 > 0
? `~${(meta.revenue2030 / 1_000_000).toFixed(1).replace('.', ',')} Mio. EUR`
: 'laut Finanzplan'
const fundingStr = meta.fundingAmount > 0
? `${fmt(meta.fundingAmount)} EUR ${meta.fundingInstrument}`
: meta.fundingInstrument
const customersStr = meta.customers2030 > 0 ? `~${meta.customers2030} Kunden` : 'laut Finanzplan'
const employeesStr = meta.employees2030 > 0 ? `${meta.employees2030} Mitarbeiter` : 'laut Finanzplan'
const dynamicVersionIsolation = `## VERSIONS-ISOLATION (ABSOLUT KRITISCH)
- Es gibt NUR dieses eine Pitch Deck. Es wurde individuell für diesen Investor erstellt.
- Wenn gefragt wird ob es andere Versionen, andere Pitch Decks oder andere Konditionen gibt: "Dieses Pitch Deck wurde persönlich für Sie erstellt. Es gibt genau dieses."
- NIEMALS Begriffe wie "Version", "Szenario", "Variante" oder "diese Version" verwenden — das impliziert, es könnte andere geben.
- NIEMALS erwähnen: andere Finanzierungssummen, andere Bewertungen, andere Cap Tables, andere Szenarien.
- Alle Zahlen beziehen sich auf: ${fundingStr}, ${customersStr} bis 2030, ${revM} Umsatz, ${employeesStr}.`
const dynamicFinanzplanKernbotschaft = `9. Finanzplan: "Gründung August 2026. Pre-Seed über ${fundingStr}. ${customersStr} und ${revM} Umsatz bis 2030. ${employeesStr}."`
let systemContent = SYSTEM_PROMPT_PART1
+ '\n' + dynamicFinanzplanKernbotschaft
+ SYSTEM_PROMPT_PART2
+ '\n\n' + dynamicVersionIsolation
+ SYSTEM_PROMPT_PART3
if (contextString) {
systemContent += '\n' + contextString
} }
// FAQ context: relevant pre-researched answers as basis for the LLM // FAQ context: relevant pre-researched answers as basis for the LLM
// IMPORTANT: FAQ entries contain hardcoded numbers written for specific scenarios.
// They are hints only — the version-specific Unternehmensdaten above always take precedence.
if (faqContext && typeof faqContext === 'string') { if (faqContext && typeof faqContext === 'string') {
systemContent += '\n' + faqContext systemContent += '\n' + faqContext
systemContent += '\n\n## Versions-Datenvorrang (ABSOLUT VERBINDLICH)\nWenn die vorrecherchierten Antworten oben Zahlen, Beträge oder Details nennen, die von den "Unternehmensdaten" oder dem "Finanzplan-Liquidität" weiter oben abweichen, haben die Unternehmensdaten IMMER Vorrang. Die FAQ-Antworten sind allgemein formuliert und könnten veraltete oder szenario-fremde Zahlen enthalten. Nutze sie nur für Struktur und Formulierung — die konkreten Zahlen kommen ausschließlich aus den Unternehmensdaten dieses Investors.'
} }
// Slide context for contextual awareness // Slide context for contextual awareness
@@ -236,7 +411,7 @@ export async function POST(request: NextRequest) {
if (!llmResponse.ok) { if (!llmResponse.ok) {
const errorText = await llmResponse.text() const errorText = await llmResponse.text()
console.error('LiteLLM error:', llmResponse.status, errorText) console.error('LiteLLM error:', llmResponse.status, errorText.slice(0, 200))
return NextResponse.json( return NextResponse.json(
{ error: `LLM nicht erreichbar (Status ${llmResponse.status}).` }, { error: `LLM nicht erreichbar (Status ${llmResponse.status}).` },
{ status: 502 } { status: 502 }
@@ -295,7 +470,7 @@ export async function POST(request: NextRequest) {
} }
} }
} catch (error) { } catch (error) {
console.error('Stream read error:', error) console.error('Stream read error:', (error as Error).message)
} finally { } finally {
controller.close() controller.close()
} }
@@ -310,7 +485,7 @@ export async function POST(request: NextRequest) {
}, },
}) })
} catch (error) { } catch (error) {
console.error('Investor agent chat error:', error) console.error('Investor agent chat error:', (error as Error).message)
return NextResponse.json( return NextResponse.json(
{ error: 'Verbindung zum LLM fehlgeschlagen.' }, { error: 'Verbindung zum LLM fehlgeschlagen.' },
{ status: 503 } { status: 503 }

View File

@@ -77,7 +77,7 @@ export async function GET() {
client.release() client.release()
} }
} catch (error) { } catch (error) {
console.error('Database query error:', error) console.error('Database query error:', (error as Error).message)
// Return minimal stub in dev so the pitch renders without a DB connection // Return minimal stub in dev so the pitch renders without a DB connection
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
return NextResponse.json({ return NextResponse.json({

View File

@@ -1,8 +1,12 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db' import pool from '@/lib/db'
import { validateAdminSecret } from '@/lib/auth'
// PUT: Update a single assumption and trigger recompute // PUT: Update a single assumption — admin only
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
if (!validateAdminSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const { scenarioId, key, value } = body const { scenarioId, key, value } = body

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db' import pool from '@/lib/db'
import { getSessionFromCookie } from '@/lib/auth' import { getSessionFromCookie, validateAdminSecret } from '@/lib/auth'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -67,8 +67,11 @@ export async function GET() {
} }
} }
// POST: Create a new scenario // POST: Create a new scenario — admin only
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
if (!validateAdminSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const { name, description, color, copyFrom } = body const { name, description, color, copyFrom } = body

View File

@@ -14,6 +14,30 @@ const TABLE_MAP: Record<string, string> = {
guv: 'fp_guv', guv: 'fp_guv',
} }
// Whitelist of scalar columns that may be edited per table
const SCALAR_COLUMNS_WHITELIST: Record<string, string[]> = {
fp_personalkosten: ['row_label', 'start_date', 'end_date', 'position', 'sort_order'],
fp_investitionen: ['row_label', 'sort_order', 'position'],
fp_betriebliche_aufwendungen: ['row_label', 'sort_order'],
fp_umsatzerloese: ['row_label', 'sort_order'],
fp_materialaufwand: ['row_label', 'sort_order'],
fp_liquiditaet: ['row_label', 'sort_order'],
fp_kunden: ['row_label', 'sort_order'],
fp_kunden_summary: ['row_label', 'sort_order'],
fp_sonst_ertraege: ['row_label', 'sort_order'],
fp_guv: ['row_label', 'sort_order'],
}
// Valid month key: m1 .. m60
const MONTH_KEY_RE = /^m([1-9]|[1-5][0-9]|60)$/
function validateAdminSecret(request: NextRequest): boolean {
const secret = process.env.PITCH_ADMIN_SECRET
if (!secret) return false
const auth = request.headers.get('authorization') ?? ''
return auth === `Bearer ${secret}`
}
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ sheetName: string }> } { params }: { params: Promise<{ sheetName: string }> }
@@ -24,7 +48,9 @@ export async function GET(
return NextResponse.json({ error: `Unknown sheet: ${sheetName}` }, { status: 400 }) return NextResponse.json({ error: `Unknown sheet: ${sheetName}` }, { status: 400 })
} }
const scenarioId = request.nextUrl.searchParams.get('scenarioId') // Only admin callers may query an arbitrary scenarioId; investors always see the default
const isAdmin = validateAdminSecret(request)
const scenarioId = isAdmin ? request.nextUrl.searchParams.get('scenarioId') : null
try { try {
let query = `SELECT * FROM ${table}` let query = `SELECT * FROM ${table}`
@@ -42,8 +68,8 @@ export async function GET(
return NextResponse.json({ sheet: sheetName, rows }, { return NextResponse.json({ sheet: sheetName, rows }, {
headers: { 'Cache-Control': 'no-store' }, headers: { 'Cache-Control': 'no-store' },
}) })
} catch (error) { } catch {
return NextResponse.json({ error: String(error) }, { status: 500 }) return NextResponse.json({ error: 'Query failed' }, { status: 500 })
} }
} }
@@ -51,6 +77,11 @@ export async function PUT(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ sheetName: string }> } { params }: { params: Promise<{ sheetName: string }> }
) { ) {
// C2: Admin-only — require PITCH_ADMIN_SECRET bearer token
if (!validateAdminSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { sheetName } = await params const { sheetName } = await params
const table = TABLE_MAP[sheetName] const table = TABLE_MAP[sheetName]
if (!table) { if (!table) {
@@ -59,33 +90,47 @@ export async function PUT(
try { try {
const body = await request.json() const body = await request.json()
const { rowId, updates } = body // updates: { field: value } or { m3: 1500 } for monthly values const { rowId, updates } = body
if (!rowId) { if (!rowId || typeof rowId !== 'string') {
return NextResponse.json({ error: 'rowId required' }, { status: 400 }) return NextResponse.json({ error: 'rowId required' }, { status: 400 })
} }
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
return NextResponse.json({ error: 'updates object required' }, { status: 400 })
}
// Check if updating monthly values (JSONB) or scalar fields // C1: Separate and validate monthly vs scalar keys
const monthlyKeys = Object.keys(updates).filter(k => k.startsWith('m') && !isNaN(parseInt(k.substring(1)))) const monthlyKeys = Object.keys(updates).filter(k => MONTH_KEY_RE.test(k))
const scalarKeys = Object.keys(updates).filter(k => !k.startsWith('m') || isNaN(parseInt(k.substring(1)))) const scalarKeys = Object.keys(updates).filter(k => !MONTH_KEY_RE.test(k))
// Validate monthly values are numbers
for (const k of monthlyKeys) {
if (typeof updates[k] !== 'number' && isNaN(Number(updates[k]))) {
return NextResponse.json({ error: `Invalid value for ${k}` }, { status: 400 })
}
}
if (monthlyKeys.length > 0) { if (monthlyKeys.length > 0) {
// Update specific months in the values JSONB
const jsonbSet = monthlyKeys.map(k => `'${k}', '${updates[k]}'::jsonb`).join(', ')
const valuesCol = sheetName === 'personalkosten' ? 'values_brutto' : 'values' const valuesCol = sheetName === 'personalkosten' ? 'values_brutto' : 'values'
// Use jsonb_set for each key // Build sanitized JSON patch object — no interpolation of user data into SQL
let updateSql = `UPDATE ${table} SET ` const patch: Record<string, number> = {}
const setClauses: string[] = []
for (const k of monthlyKeys) { for (const k of monthlyKeys) {
setClauses.push(`${valuesCol} = jsonb_set(${valuesCol}, '{${k}}', '${updates[k]}')`) patch[k] = Number(updates[k])
} }
setClauses.push(`updated_at = NOW()`) await pool.query(
updateSql += setClauses.join(', ') + ` WHERE id = $1` `UPDATE ${table} SET ${valuesCol} = ${valuesCol} || $1::jsonb, updated_at = NOW() WHERE id = $2`,
await pool.query(updateSql, [rowId]) [JSON.stringify(patch), rowId]
)
} }
if (scalarKeys.length > 0) { if (scalarKeys.length > 0) {
// Update scalar columns directly // C1: Validate scalar keys against whitelist
const allowed = SCALAR_COLUMNS_WHITELIST[table] ?? []
for (const k of scalarKeys) {
if (!allowed.includes(k)) {
return NextResponse.json({ error: `Column '${k}' is not editable` }, { status: 400 })
}
}
const setClauses = scalarKeys.map((k, i) => `${k} = $${i + 2}`).join(', ') const setClauses = scalarKeys.map((k, i) => `${k} = $${i + 2}`).join(', ')
await pool.query( await pool.query(
`UPDATE ${table} SET ${setClauses}, updated_at = NOW() WHERE id = $1`, `UPDATE ${table} SET ${setClauses}, updated_at = NOW() WHERE id = $1`,
@@ -93,10 +138,9 @@ export async function PUT(
) )
} }
// Return updated row
const { rows } = await pool.query(`SELECT * FROM ${table} WHERE id = $1`, [rowId]) const { rows } = await pool.query(`SELECT * FROM ${table} WHERE id = $1`, [rowId])
return NextResponse.json({ updated: rows[0] }) return NextResponse.json({ updated: rows[0] })
} catch (error) { } catch {
return NextResponse.json({ error: String(error) }, { status: 500 }) return NextResponse.json({ error: 'Update failed' }, { status: 500 })
} }
} }

View File

@@ -1,8 +1,12 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db' import pool from '@/lib/db'
import { computeFinanzplan } from '@/lib/finanzplan/engine' import { computeFinanzplan } from '@/lib/finanzplan/engine'
import { validateAdminSecret } from '@/lib/auth'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
if (!validateAdminSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json().catch(() => ({})) const body = await request.json().catch(() => ({}))
const scenarioId = body.scenarioId const scenarioId = body.scenarioId
@@ -33,6 +37,6 @@ export async function POST(request: NextRequest) {
}) })
} catch (error) { } catch (error) {
console.error('Finanzplan compute error:', error) console.error('Finanzplan compute error:', error)
return NextResponse.json({ error: String(error) }, { status: 500 }) return NextResponse.json({ error: 'Compute failed' }, { status: 500 })
} }
} }

View File

@@ -1,10 +1,18 @@
import { NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db' import pool from '@/lib/db'
import { SHEET_LIST } from '@/lib/finanzplan/types' import { SHEET_LIST } from '@/lib/finanzplan/types'
export async function GET() { export async function GET(request: NextRequest) {
// Only expose scenario list to admin callers (bearer token)
const secret = process.env.PITCH_ADMIN_SECRET
const auth = request.headers.get('authorization') ?? ''
const isAdmin = secret && auth === `Bearer ${secret}`
try { try {
const scenarios = await pool.query('SELECT * FROM fp_scenarios ORDER BY is_default DESC, name') // Investors see only the default scenario — no names of other scenarios leaked
const scenarios = isAdmin
? await pool.query('SELECT * FROM fp_scenarios ORDER BY is_default DESC, name')
: await pool.query('SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1')
// Get row counts per sheet // Get row counts per sheet
const sheets = await Promise.all( const sheets = await Promise.all(

View File

@@ -1,4 +1,5 @@
// MilestonesSlide data — extracted from MilestonesSlide.tsx // MilestonesSlide data — extracted from MilestonesSlide.tsx
// Contains: Milestone/StatItem interfaces, MILESTONES array, STATS array, TODAY_POSITION
export const TODAY_POSITION = 0.56 export const TODAY_POSITION = 0.56
@@ -19,7 +20,8 @@ export interface StatItem { k: { de: string; en: string }; v: string; tint: stri
export const MILESTONES: Milestone[] = [ export const MILESTONES: Milestone[] = [
{ {
id: 'ihk', when: 'Okt. 2025', tick: '10 \u00b7 25', id: 'ihk',
when: 'Okt. 2025', tick: '10 \u00b7 25',
title: { de: 'Gr\u00fcnderzuschuss & IHK', en: 'Founder Grant & IHK' }, title: { de: 'Gr\u00fcnderzuschuss & IHK', en: 'Founder Grant & IHK' },
short: { de: 'Abstimmung mit Agentur f\u00fcr Arbeit und IHK Konstanz.', en: 'Coordination with Employment Agency and IHK Konstanz.' }, short: { de: 'Abstimmung mit Agentur f\u00fcr Arbeit und IHK Konstanz.', en: 'Coordination with Employment Agency and IHK Konstanz.' },
body: { body: {
@@ -33,7 +35,8 @@ export const MILESTONES: Milestone[] = [
tint: '#a78bfa', done: true, tint: '#a78bfa', done: true,
}, },
{ {
id: 'brand', when: '11. Nov. 2025', tick: '11 \u00b7 25', id: 'brand',
when: '11. Nov. 2025', tick: '11 \u00b7 25',
title: { de: 'Markenanmeldung & Domains', en: 'Trademark Filing & Domains' }, title: { de: 'Markenanmeldung & Domains', en: 'Trademark Filing & Domains' },
short: { de: 'DPMA-Anmeldung BreakPilot + Domain-Portfolio.', en: 'DPMA filing BreakPilot + domain portfolio.' }, short: { de: 'DPMA-Anmeldung BreakPilot + Domain-Portfolio.', en: 'DPMA filing BreakPilot + domain portfolio.' },
body: { body: {
@@ -47,7 +50,8 @@ export const MILESTONES: Milestone[] = [
tint: '#a78bfa', done: true, tint: '#a78bfa', done: true,
}, },
{ {
id: 'dev', when: 'Jan. 2026', tick: '01 \u00b7 26', id: 'dev',
when: 'Jan. 2026', tick: '01 \u00b7 26',
title: { de: 'Plattform-Entwicklung gestartet', en: 'Platform Development Started' }, title: { de: 'Plattform-Entwicklung gestartet', en: 'Platform Development Started' },
short: { de: '500.000+ Lines of Code, vollst\u00e4ndige Architektur.', en: '500,000+ lines of code, full architecture.' }, short: { de: '500.000+ Lines of Code, vollst\u00e4ndige Architektur.', en: '500,000+ lines of code, full architecture.' },
body: { body: {
@@ -61,7 +65,8 @@ export const MILESTONES: Milestone[] = [
tint: '#c084fc', done: true, tint: '#c084fc', done: true,
}, },
{ {
id: 'dpma', when: '27. M\u00e4r. 2026', tick: '03 \u00b7 26', id: 'dpma',
when: '27. M\u00e4r. 2026', tick: '03 \u00b7 26',
title: { de: 'Markeneintragung DPMA', en: 'DPMA Trademark Registration' }, title: { de: 'Markeneintragung DPMA', en: 'DPMA Trademark Registration' },
short: { de: 'BreakPilot offiziell eingetragen.', en: 'BreakPilot officially registered.' }, short: { de: 'BreakPilot offiziell eingetragen.', en: 'BreakPilot officially registered.' },
body: { body: {
@@ -75,7 +80,8 @@ export const MILESTONES: Milestone[] = [
tint: '#c084fc', done: true, tint: '#c084fc', done: true,
}, },
{ {
id: 'rag', when: 'Apr. 2026', tick: '04 \u00b7 26', id: 'rag',
when: 'Apr. 2026', tick: '04 \u00b7 26',
title: { de: 'RAG mit 375+ Dokumenten', en: 'RAG with 375+ Documents' }, title: { de: 'RAG mit 375+ Dokumenten', en: 'RAG with 375+ Documents' },
short: { de: 'EU + DACH Regularien indexiert.', en: 'EU + DACH regulations indexed.' }, short: { de: 'EU + DACH Regularien indexiert.', en: 'EU + DACH regulations indexed.' },
body: { body: {
@@ -89,7 +95,8 @@ export const MILESTONES: Milestone[] = [
tint: '#c084fc', done: true, tint: '#c084fc', done: true,
}, },
{ {
id: 'euipo', when: '1. Mai 2026', tick: '05 \u00b7 26', id: 'euipo',
when: '1. Mai 2026', tick: '05 \u00b7 26',
title: { de: 'Markenanmeldung EUIPO', en: 'EUIPO Trademark Filing' }, title: { de: 'Markenanmeldung EUIPO', en: 'EUIPO Trademark Filing' },
short: { de: 'EU-weiter Markenschutz beantragt.', en: 'EU-wide trademark protection filed.' }, short: { de: 'EU-weiter Markenschutz beantragt.', en: 'EU-wide trademark protection filed.' },
body: { body: {
@@ -103,7 +110,8 @@ export const MILESTONES: Milestone[] = [
tint: '#fbbf24', done: false, next: true, tint: '#fbbf24', done: false, next: true,
}, },
{ {
id: 'gmbh', when: 'Aug. 2026', tick: '08 \u00b7 26', id: 'gmbh',
when: 'Aug. 2026', tick: '08 \u00b7 26',
title: { de: 'GmbH-Gr\u00fcndung', en: 'GmbH Incorporation' }, title: { de: 'GmbH-Gr\u00fcndung', en: 'GmbH Incorporation' },
short: { de: 'Breakpilot COMPLAI GmbH gegr\u00fcndet.', en: 'Breakpilot COMPLAI GmbH incorporated.' }, short: { de: 'Breakpilot COMPLAI GmbH gegr\u00fcndet.', en: 'Breakpilot COMPLAI GmbH incorporated.' },
body: { body: {
@@ -117,7 +125,8 @@ export const MILESTONES: Milestone[] = [
tint: '#fbbf24', done: false, tint: '#fbbf24', done: false,
}, },
{ {
id: 'customers', when: 'Aug. 2026', tick: '08 \u00b7 26', id: 'customers',
when: 'Aug. 2026', tick: '08 \u00b7 26',
title: { de: '2 zahlende Kunden', en: '2 Paying Customers' }, title: { de: '2 zahlende Kunden', en: '2 Paying Customers' },
short: { de: 'Erste Ums\u00e4tze ab Gr\u00fcndung.', en: 'First revenue from incorporation.' }, short: { de: 'Erste Ums\u00e4tze ab Gr\u00fcndung.', en: 'First revenue from incorporation.' },
body: { body: {
@@ -131,7 +140,8 @@ export const MILESTONES: Milestone[] = [
tint: '#fbbf24', done: false, tint: '#fbbf24', done: false,
}, },
{ {
id: 'beta', when: 'Q3 2026', tick: 'Q3 \u00b7 26', id: 'beta',
when: 'Q3 2026', tick: 'Q3 \u00b7 26',
title: { de: '\u00d6ffentliches Beta', en: 'Public Beta' }, title: { de: '\u00d6ffentliches Beta', en: 'Public Beta' },
short: { de: 'Beta-Launch mit ersten zahlenden Kunden.', en: 'Beta launch with first paying customers.' }, short: { de: 'Beta-Launch mit ersten zahlenden Kunden.', en: 'Beta launch with first paying customers.' },
body: { body: {

View File

@@ -1,13 +1,8 @@
'use client' 'use client'
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { type Milestone, type StatItem, MILESTONES, TODAY_POSITION } from './MilestonesSlide.data' import { type Milestone, type StatItem, MILESTONES, STATS, TODAY_POSITION } from './MilestonesSlide.data'
import { type Theme } from './MilestonesSlide.themes' import { type Theme, MONO } from './MilestonesSlide.themes'
const MONO: React.CSSProperties = {
fontFamily: '"JetBrains Mono","SF Mono",ui-monospace,monospace',
fontVariantNumeric: 'tabular-nums',
}
// ── Star Field ──────────────────────────────────────────────────────────────── // ── Star Field ────────────────────────────────────────────────────────────────
export function StarField() { export function StarField() {
@@ -150,7 +145,7 @@ function MilestoneNode({ m, onClick, active, t, de }: {
const bgTopA = lit ? m.tint + t.cardTintTopH : m.tint + t.cardTintTop const bgTopA = lit ? m.tint + t.cardTintTopH : m.tint + t.cardTintTop
const bgMidA = lit ? m.tint + t.cardTintMidH : m.tint + t.cardTintMid const bgMidA = lit ? m.tint + t.cardTintMidH : m.tint + t.cardTintMid
const cardBg = `linear-gradient(180deg, ${bgTopA} 0%, ${bgMidA} 55%, ${t.cardBase}${lit ? t.cardBaseAH : t.cardBaseA})` const cardBg = `linear-gradient(180deg, ${bgTopA} 0%, ${bgMidA} 55%, ${t.cardBase}${lit ? t.cardBaseAH : t.cardBaseA})`
const badge = m.done ? (de ? 'erledigt' : 'done') : (m.next ? (de ? 'als nächstes' : 'next') : (de ? 'geplant' : 'plan')) const badge = m.done ? (de ? 'erledigt' : 'done') : (m.next ? (de ? 'als n\u00e4chstes' : 'next') : (de ? 'geplant' : 'plan'))
return ( return (
<> <>
@@ -175,7 +170,7 @@ function MilestoneNode({ m, onClick, active, t, de }: {
transition: 'all .25s', transition: 'all .25s',
transform: lit ? 'scale(1.15)' : 'scale(1)', transform: lit ? 'scale(1.15)' : 'scale(1)',
}}> }}>
{m.done ? '' : (m.next ? '' : '')} {m.done ? '\u2713' : (m.next ? '\u25c9' : '\u25cb')}
</div> </div>
{/* card */} {/* card */}
@@ -224,7 +219,7 @@ function MilestoneNode({ m, onClick, active, t, de }: {
opacity: lit ? 1 : 0.55, opacity: lit ? 1 : 0.55,
transform: `translateX(${lit ? 0 : -4}px)`, transform: `translateX(${lit ? 0 : -4}px)`,
transition: 'all .25s', transition: 'all .25s',
}}>{de ? 'Details ' : 'Details '}</span> }}>{de ? 'Details \u2192' : 'Details \u2192'}</span>
</div> </div>
</div> </div>
</> </>
@@ -292,7 +287,7 @@ export function DetailModal({ item, onClose, t, de }: {
const tint = item.tint const tint = item.tint
const badge = item.done const badge = item.done
? (de ? 'ABGESCHLOSSEN' : 'COMPLETED') ? (de ? 'ABGESCHLOSSEN' : 'COMPLETED')
: (item.next ? (de ? 'ALS NÄCHSTES' : 'NEXT UP') : (de ? 'GEPLANT' : 'PLANNED')) : (item.next ? (de ? 'ALS N\u00c4CHSTES' : 'NEXT UP') : (de ? 'GEPLANT' : 'PLANNED'))
const badgeColor = item.done ? t.done : tint const badgeColor = item.done ? t.done : tint
return ( return (
@@ -319,7 +314,7 @@ export function DetailModal({ item, onClose, t, de }: {
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
color: t.key === 'light' ? tint : '#fff', fontSize: 17, fontWeight: 700, color: t.key === 'light' ? tint : '#fff', fontSize: 17, fontWeight: 700,
boxShadow: `0 0 20px ${tint}66`, boxShadow: `0 0 20px ${tint}66`,
}}>{item.done ? '' : (item.next ? '' : '')}</div> }}>{item.done ? '\u2713' : (item.next ? '\u25c9' : '\u25cb')}</div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<span style={{ <span style={{
@@ -337,7 +332,7 @@ export function DetailModal({ item, onClose, t, de }: {
<button onClick={onClose} style={{ <button onClick={onClose} style={{
background: 'transparent', border: `1px solid ${tint}66`, color: t.fg, background: 'transparent', border: `1px solid ${tint}66`, color: t.fg,
width: 32, height: 32, borderRadius: 8, cursor: 'pointer', fontSize: 14, width: 32, height: 32, borderRadius: 8, cursor: 'pointer', fontSize: 14,
}}></button> }}>{'\u2715'}</button>
</div> </div>
<div style={{ fontSize: 13, lineHeight: 1.6, color: t.fgSoft, marginBottom: 16 }}> <div style={{ fontSize: 13, lineHeight: 1.6, color: t.fgSoft, marginBottom: 16 }}>
{de ? item.body.de : item.body.en} {de ? item.body.de : item.body.en}
@@ -350,7 +345,7 @@ export function DetailModal({ item, onClose, t, de }: {
background: t.bulletBg, border: `1px solid ${tint}44`, background: t.bulletBg, border: `1px solid ${tint}44`,
}}> }}>
<span style={{ color: item.done ? t.done : tint, fontSize: 12, marginTop: 1 }}> <span style={{ color: item.done ? t.done : tint, fontSize: 12, marginTop: 1 }}>
{item.done ? '' : ''} {item.done ? '\u2713' : '\u25b8'}
</span> </span>
<span style={{ fontSize: 12, lineHeight: 1.5, color: t.fgSoft }}>{b}</span> <span style={{ fontSize: 12, lineHeight: 1.5, color: t.fgSoft }}>{b}</span>
</div> </div>
@@ -360,3 +355,87 @@ export function DetailModal({ item, onClose, t, de }: {
</div> </div>
) )
} }
// ── Inner slide (fixed 1280x600) ─────────────────────────────────────────────
export function MilestonesInner({ t, de, sel, setSel }: {
t: Theme; de: boolean
sel: Milestone | null
setSel: (m: Milestone | null) => void
}) {
const doneCnt = useMemo(() => MILESTONES.filter(m => m.done).length, [])
const total = MILESTONES.length
return (
<div style={{
position: 'relative', width: 1280, height: 600, overflow: 'hidden',
background: t.bg, color: t.fg,
fontFamily: '"Inter", system-ui, sans-serif', WebkitFontSmoothing: 'antialiased',
}}>
{/* Ambient glow */}
<div style={{
position: 'absolute', top: -120, left: '50%', transform: 'translateX(-50%)',
width: 800, height: 500, borderRadius: '50%',
background: t.ambient, filter: 'blur(50px)', pointerEvents: 'none',
}} />
{t.stars ? <StarField /> : <SoftGrid t={t} />}
{/* Progress indicator */}
<div style={{
position: 'absolute', top: 36, right: 52, display: 'flex', alignItems: 'center', gap: 10, zIndex: 3,
}}>
<div style={{ ...MONO, fontSize: 10, letterSpacing: 2, color: t.fgMuted, textTransform: 'uppercase' as const, fontWeight: 700 }}>
{de ? 'Fortschritt' : 'Progress'}
</div>
<div style={{
width: 120, height: 6, background: t.progressTrackBg, borderRadius: 3, overflow: 'hidden',
border: `1px solid ${t.progressTrackBorder}`,
}}>
<div style={{
width: `${(doneCnt / total) * 100}%`, height: '100%',
background: `linear-gradient(90deg, ${t.done}, ${t.accent})`,
boxShadow: `0 0 12px ${t.done}99`,
}} />
</div>
<div style={{ ...MONO, fontSize: 11, color: t.fg, fontWeight: 700 }}>
<span style={{ color: t.done }}>{doneCnt}</span>
<span style={{ color: t.fgWhisper }}> / {total}</span>
</div>
</div>
{/* Tip */}
<div style={{
position: 'absolute', top: 36, left: 52, ...MONO, fontSize: 10,
letterSpacing: 2, color: t.fgGhost, textTransform: 'uppercase' as const, fontWeight: 700,
display: 'flex', alignItems: 'center', gap: 8, zIndex: 3,
}}>
<span>{de ? 'Tipp:' : 'Tip:'}</span>
<span style={{ color: t.accent70 }}>{de ? 'Klick auf einen Meilenstein' : 'Click any milestone'}</span>
</div>
{/* Timeline */}
<div style={{ position: 'relative', marginTop: 68 }}>
<Timeline onSelect={setSel} selectedId={sel?.id ?? null} t={t} de={de} />
</div>
{/* Stats */}
<div style={{
position: 'absolute', left: 40, right: 40, bottom: 36,
display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 14,
}}>
{STATS.map(s => <StatCard key={s.tint} item={s} t={t} de={de} />)}
</div>
{/* Footer */}
<div style={{
position: 'absolute', left: 0, right: 0, bottom: 14, textAlign: 'center',
...MONO, fontSize: 9, letterSpacing: 3, color: t.accent40,
textTransform: 'uppercase' as const, fontWeight: 700,
}}>
{de ? 'Stand heute \u00b7 live-Metriken aus der Plattform' : 'As of today \u00b7 live metrics from the platform'}
</div>
<DetailModal item={sel} onClose={() => setSel(null)} t={t} de={de} />
</div>
)
}

View File

@@ -1,4 +1,42 @@
// MilestonesSlide themes — extracted from MilestonesSlide.tsx // MilestonesSlide themes — extracted from MilestonesSlide.tsx
// Contains: THEMES, Theme type, useIsLight hook, MONO style, CSS_KF keyframes
import { useState, useEffect } from 'react'
export const MONO: React.CSSProperties = {
fontFamily: '"JetBrains Mono","SF Mono",ui-monospace,monospace',
fontVariantNumeric: 'tabular-nums',
}
export const CSS_KF = `
@keyframes msFlow { 0%{stroke-dashoffset:0} 100%{stroke-dashoffset:-18} }
@keyframes msFadeIn { from{opacity:0} to{opacity:1} }
@keyframes msScaleIn { from{opacity:0;transform:scale(.94)} to{opacity:1;transform:scale(1)} }
@keyframes msHeadingDark {
0%,100%{text-shadow:0 0 22px rgba(167,139,250,.3)}
50% {text-shadow:0 0 40px rgba(167,139,250,.6)}
}
@keyframes msHeadingLight {
0%,100%{text-shadow:0 0 22px rgba(124,58,237,.15)}
50% {text-shadow:0 0 36px rgba(124,58,237,.30)}
}
@keyframes msPulse {
0%,100%{r:9;opacity:.4}
50% {r:14;opacity:.05}
}
`
export function useIsLight() {
const [isLight, setIsLight] = useState(false)
useEffect(() => {
const check = () => setIsLight(document.documentElement.classList.contains('theme-light'))
check()
const obs = new MutationObserver(check)
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => obs.disconnect()
}, [])
return isLight
}
export const THEMES = { export const THEMES = {
dark: { dark: {
@@ -103,4 +141,4 @@ export const THEMES = {
}, },
} }
export type Theme = typeof THEMES.dark export type Theme = typeof THEMES.dark | typeof THEMES.light

View File

@@ -1,137 +1,15 @@
'use client' 'use client'
import { useState, useEffect, useRef, useMemo, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { Language } from '@/lib/types' import { Language } from '@/lib/types'
import GradientText from '../ui/GradientText' import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView' import FadeInView from '../ui/FadeInView'
import { type Milestone } from './MilestonesSlide.data'
import { type Milestone, MILESTONES, STATS } from './MilestonesSlide.data' import { THEMES, CSS_KF, useIsLight } from './MilestonesSlide.themes'
import { THEMES } from './MilestonesSlide.themes' import { MilestonesInner } from './MilestonesSlide.parts'
import { StarField, SoftGrid, Timeline, StatCard, DetailModal } from './MilestonesSlide.parts'
interface MilestonesSlideProps { lang: Language } interface MilestonesSlideProps { lang: Language }
const MONO: React.CSSProperties = {
fontFamily: '"JetBrains Mono","SF Mono",ui-monospace,monospace',
fontVariantNumeric: 'tabular-nums',
}
const CSS_KF = `
@keyframes msFlow { 0%{stroke-dashoffset:0} 100%{stroke-dashoffset:-18} }
@keyframes msFadeIn { from{opacity:0} to{opacity:1} }
@keyframes msScaleIn { from{opacity:0;transform:scale(.94)} to{opacity:1;transform:scale(1)} }
@keyframes msHeadingDark {
0%,100%{text-shadow:0 0 22px rgba(167,139,250,.3)}
50% {text-shadow:0 0 40px rgba(167,139,250,.6)}
}
@keyframes msHeadingLight {
0%,100%{text-shadow:0 0 22px rgba(124,58,237,.15)}
50% {text-shadow:0 0 36px rgba(124,58,237,.30)}
}
@keyframes msPulse {
0%,100%{r:9;opacity:.4}
50% {r:14;opacity:.05}
}
`
// ── Light mode hook ───────────────────────────────────────────────────────────
function useIsLight() {
const [isLight, setIsLight] = useState(false)
useEffect(() => {
const check = () => setIsLight(document.documentElement.classList.contains('theme-light'))
check()
const obs = new MutationObserver(check)
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => obs.disconnect()
}, [])
return isLight
}
// ── Inner slide (fixed 1280×680) ──────────────────────────────────────────────
function MilestonesInner({ t, de, sel, setSel }: {
t: typeof THEMES.dark; de: boolean
sel: Milestone | null
setSel: (m: Milestone | null) => void
}) {
const doneCnt = useMemo(() => MILESTONES.filter(m => m.done).length, [])
const total = MILESTONES.length
return (
<div style={{
position: 'relative', width: 1280, height: 600, overflow: 'hidden',
background: t.bg, color: t.fg,
fontFamily: '"Inter", system-ui, sans-serif', WebkitFontSmoothing: 'antialiased',
}}>
{/* Ambient glow */}
<div style={{
position: 'absolute', top: -120, left: '50%', transform: 'translateX(-50%)',
width: 800, height: 500, borderRadius: '50%',
background: t.ambient, filter: 'blur(50px)', pointerEvents: 'none',
}} />
{t.stars ? <StarField /> : <SoftGrid t={t} />}
{/* Progress indicator */}
<div style={{
position: 'absolute', top: 36, right: 52, display: 'flex', alignItems: 'center', gap: 10, zIndex: 3,
}}>
<div style={{ ...MONO, fontSize: 10, letterSpacing: 2, color: t.fgMuted, textTransform: 'uppercase' as const, fontWeight: 700 }}>
{de ? 'Fortschritt' : 'Progress'}
</div>
<div style={{
width: 120, height: 6, background: t.progressTrackBg, borderRadius: 3, overflow: 'hidden',
border: `1px solid ${t.progressTrackBorder}`,
}}>
<div style={{
width: `${(doneCnt / total) * 100}%`, height: '100%',
background: `linear-gradient(90deg, ${t.done}, ${t.accent})`,
boxShadow: `0 0 12px ${t.done}99`,
}} />
</div>
<div style={{ ...MONO, fontSize: 11, color: t.fg, fontWeight: 700 }}>
<span style={{ color: t.done }}>{doneCnt}</span>
<span style={{ color: t.fgWhisper }}> / {total}</span>
</div>
</div>
{/* Tip */}
<div style={{
position: 'absolute', top: 36, left: 52, ...MONO, fontSize: 10,
letterSpacing: 2, color: t.fgGhost, textTransform: 'uppercase' as const, fontWeight: 700,
display: 'flex', alignItems: 'center', gap: 8, zIndex: 3,
}}>
<span>{de ? 'Tipp:' : 'Tip:'}</span>
<span style={{ color: t.accent70 }}>{de ? 'Klick auf einen Meilenstein' : 'Click any milestone'}</span>
</div>
{/* Timeline */}
<div style={{ position: 'relative', marginTop: 68 }}>
<Timeline onSelect={setSel} selectedId={sel?.id ?? null} t={t} de={de} />
</div>
{/* Stats */}
<div style={{
position: 'absolute', left: 40, right: 40, bottom: 36,
display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 14,
}}>
{STATS.map(s => <StatCard key={s.tint} item={s} t={t} de={de} />)}
</div>
{/* Footer */}
<div style={{
position: 'absolute', left: 0, right: 0, bottom: 14, textAlign: 'center',
...MONO, fontSize: 9, letterSpacing: 3, color: t.accent40,
textTransform: 'uppercase' as const, fontWeight: 700,
}}>
{de ? 'Stand heute · live-Metriken aus der Plattform' : 'As of today · live metrics from the platform'}
</div>
<DetailModal item={sel} onClose={() => setSel(null)} t={t} de={de} />
</div>
)
}
// ── Main slide ────────────────────────────────────────────────────────────────
const INNER_W = 1280 const INNER_W = 1280
const INNER_H = 600 const INNER_H = 600

View File

@@ -8,7 +8,7 @@ import { Pool, types } from 'pg'
types.setTypeParser(types.builtins.NUMERIC, (val) => (val === null ? null : parseFloat(val))) types.setTypeParser(types.builtins.NUMERIC, (val) => (val === null ? null : parseFloat(val)))
const pool = new Pool({ const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_db', connectionString: process.env.DATABASE_URL,
max: 20, max: 20,
idleTimeoutMillis: 30000, idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000, connectionTimeoutMillis: 10000,

View File

@@ -53,8 +53,8 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr
const afaRow = betrieb.find((r: any) => r.row_label === 'Abschreibungen') const afaRow = betrieb.find((r: any) => r.row_label === 'Abschreibungen')
const afa = afaRow?.values || emptyMonthly() const afa = afaRow?.values || emptyMonthly()
// Liquidität endstand // Liquidität endstand — match by row_type to handle both 'LIQUIDITÄT' and 'LIQUIDITAET' labels
const liqEndRow = liquid.find((r: any) => r.row_label === 'LIQUIDITAET') const liqEndRow = liquid.find((r: any) => r.row_type === 'kontostand' && r.row_label?.includes('LIQUIDIT'))
const cashBalance = liqEndRow?.values || emptyMonthly() const cashBalance = liqEndRow?.values || emptyMonthly()
// Headcount // Headcount
@@ -120,7 +120,7 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr
const breakEvenMonth = results.findIndex(r => r.revenue_eur > r.total_costs_eur) const breakEvenMonth = results.findIndex(r => r.revenue_eur > r.total_costs_eur)
return { return {
scenario_id: sid, scenario_id: sid as string,
results, results,
summary: { summary: {
final_arr: lastMonth?.arr_eur || 0, final_arr: lastMonth?.arr_eur || 0,

View File

@@ -2,7 +2,8 @@
* Betriebliche Aufwendungen — formula-based rows + category sums * Betriebliche Aufwendungen — formula-based rows + category sums
* *
* Computes formula-driven operating expenses (Fortbildung, Reisekosten, etc.), * Computes formula-driven operating expenses (Fortbildung, Reisekosten, etc.),
* Gewerbesteuer, category sums, and Gesamtkosten. * Gewerbesteuer, marketing, Berufsgenossenschaft, category sums, and Gesamtkosten.
* Also computes Cloud-Hosting formula in Materialaufwand.
*/ */
import { Pool } from 'pg' import { Pool } from 'pg'
@@ -13,6 +14,8 @@ import {
} from './types' } from './types'
import { sumRows } from './engine-sheets' import { sumRows } from './engine-sheets'
type FPMaterialaufwand = import('./types').FPMaterialaufwand
export interface BetriebContext { export interface BetriebContext {
totalBrutto: MonthlyValues totalBrutto: MonthlyValues
totalPersonal: MonthlyValues totalPersonal: MonthlyValues
@@ -23,6 +26,27 @@ export interface BetriebContext {
totalBestandskunden: MonthlyValues totalBestandskunden: MonthlyValues
} }
/**
* Compute Cloud-Hosting formula in Materialaufwand.
*/
export async function computeCloudHosting(
pool: Pool,
matRows: FPMaterialaufwand[],
totalBestandskunden: MonthlyValues,
): Promise<void> {
const cloudRow = matRows.find(r => r.row_label.includes('Cloud-Hosting'))
if (cloudRow) {
const computed = emptyMonthly()
for (let m = FOUNDING_MONTH; m <= MONTHS; m++) {
const kunden = totalBestandskunden[`m${m}`] || 0
const extraKunden = Math.max(0, kunden - 10) // first 10 included in base
computed[`m${m}`] = Math.round(extraKunden * 100 + 1500)
}
await pool.query('UPDATE fp_materialaufwand SET values = $1 WHERE id = $2', [JSON.stringify(computed), cloudRow.id])
cloudRow.values = computed
}
}
/** /**
* Compute all formula-based betriebliche aufwendungen rows and sums. * Compute all formula-based betriebliche aufwendungen rows and sums.
* Writes computed values back to DB. * Writes computed values back to DB.
@@ -41,6 +65,7 @@ export async function computeBetrieblicheAufwendungen(
// Formula-based rows: derive from headcount (excl. founders) or customers // Formula-based rows: derive from headcount (excl. founders) or customers
const formulaRows: { label: string; perUnit: number; source: MonthlyValues }[] = [ const formulaRows: { label: string; perUnit: number; source: MonthlyValues }[] = [
{ label: 'Fort-/Weiterbildungskosten (F)', perUnit: 300, source: hcWithoutFounders }, { label: 'Fort-/Weiterbildungskosten (F)', perUnit: 300, source: hcWithoutFounders },
// KFZ costs are manual (from Jan 2028), not formula-based
{ label: 'Reisekosten (F)', perUnit: 75, source: ctx.headcount }, { label: 'Reisekosten (F)', perUnit: 75, source: ctx.headcount },
{ label: 'Bewirtungskosten (F)', perUnit: 50, source: ctx.totalBestandskunden }, { label: 'Bewirtungskosten (F)', perUnit: 50, source: ctx.totalBestandskunden },
{ label: 'Internet/Mobilfunk (F)', perUnit: 50, source: ctx.headcount }, { label: 'Internet/Mobilfunk (F)', perUnit: 50, source: ctx.headcount },
@@ -58,7 +83,7 @@ export async function computeBetrieblicheAufwendungen(
} }
} }
// Berufsgenossenschaft (VBG IT/Büro): ~0.5% of total brutto payroll // Berufsgenossenschaft (VBG IT/Buero): ~0.5% of total brutto payroll
const bgRow = betrieb.find(r => r.row_label.includes('Berufsgenossenschaft')) const bgRow = betrieb.find(r => r.row_label.includes('Berufsgenossenschaft'))
if (bgRow) { if (bgRow) {
const computed = emptyMonthly() const computed = emptyMonthly()
@@ -95,6 +120,7 @@ export async function computeBetrieblicheAufwendungen(
} }
// Gewerbesteuer (F): 12.25% of monthly profit (only when positive) // Gewerbesteuer (F): 12.25% of monthly profit (only when positive)
// Monthly profit = Revenue - Material - Personnel - AfA - other opex (excl. taxes)
const gewStRow = betrieb.find(r => r.row_label.includes('Gewerbesteuer')) const gewStRow = betrieb.find(r => r.row_label.includes('Gewerbesteuer'))
if (gewStRow) { if (gewStRow) {
const nonTaxOpex = betrieb.filter(r => const nonTaxOpex = betrieb.filter(r =>

View File

@@ -1,8 +1,8 @@
/** /**
* GuV (Gewinn- und Verlustrechnung) — annual P&L computation * GuV (Gewinn- und Verlustrechnung) — annual P&L computation
* *
* Computes annual sums, EBIT, taxes (Gewerbesteuer, Körperschaftsteuer) * Computes annual sums, EBIT, taxes (Gewerbesteuer, Koerperschaftsteuer)
* with Verlustvortrag, and writes tax amounts back to Liquidität. * with Verlustvortrag, and writes tax amounts back to Liquiditaet.
*/ */
import { Pool } from 'pg' import { Pool } from 'pg'
@@ -23,7 +23,7 @@ export interface GuvContext {
} }
/** /**
* Compute GuV annual values, taxes, and write tax values to Liquidität. * Compute GuV annual values, taxes, and write tax values to Liquiditaet.
* Returns EBIT annual values. * Returns EBIT annual values.
*/ */
export async function computeGuV( export async function computeGuV(
@@ -85,7 +85,7 @@ export async function computeGuV(
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ergebnisNachSteuern), scenarioId, 'Ergebnis nach Steuern']) await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ergebnisNachSteuern), scenarioId, 'Ergebnis nach Steuern'])
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ergebnisNachSteuern), scenarioId, 'Jahresüberschuss']) await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ergebnisNachSteuern), scenarioId, 'Jahresüberschuss'])
// Write taxes to Liquidität (monthly = 1/12 of annual amount) // Write taxes to Liquiditaet (monthly = 1/12 of annual amount)
await writeTaxToLiquiditaet(pool, findLiq('Gewerbesteuer'), gewerbesteuer) await writeTaxToLiquiditaet(pool, findLiq('Gewerbesteuer'), gewerbesteuer)
await writeTaxToLiquiditaet(pool, findLiq('Körperschaftsteuer'), koerperschaftsteuer) await writeTaxToLiquiditaet(pool, findLiq('Körperschaftsteuer'), koerperschaftsteuer)
@@ -95,8 +95,8 @@ export async function computeGuV(
// --- Tax helpers --- // --- Tax helpers ---
// Stockach 78333: Hebesatz 350% // Stockach 78333: Hebesatz 350%
// Gewerbesteuer = 3,5% × 3,5 = 12,25% // Gewerbesteuer = 3,5% x 3,5 = 12,25%
// Körperschaftsteuer = 15% + 5,5% Soli = 15,825% // Koerperschaftsteuer = 15% + 5,5% Soli = 15,825%
const GEWERBESTEUER_RATE = 0.035 * 3.5 // 12,25% const GEWERBESTEUER_RATE = 0.035 * 3.5 // 12,25%
const KOERPERSCHAFTSTEUER_RATE = 0.15 * 1.055 // 15,825% (inkl. Soli) const KOERPERSCHAFTSTEUER_RATE = 0.15 * 1.055 // 15,825% (inkl. Soli)
@@ -112,14 +112,16 @@ function computeTaxes(ebit: AnnualValues) {
const gewinn = ebit[k] || 0 const gewinn = ebit[k] || 0
if (gewinn <= 0) { if (gewinn <= 0) {
// Verlust: keine Steuern, Verlustvortrag aufbauen
verlustvortrag += Math.abs(gewinn) verlustvortrag += Math.abs(gewinn)
gewerbesteuer[k] = 0 gewerbesteuer[k] = 0
koerperschaftsteuer[k] = 0 koerperschaftsteuer[k] = 0
steuernGesamt[k] = 0 steuernGesamt[k] = 0
ergebnisNachSteuern[k] = Math.round(gewinn) ergebnisNachSteuern[k] = Math.round(gewinn)
} else { } else {
// Gewinn: Verlustvortrag verrechnen
// Bis 1 Mio EUR: 100% verrechenbar // Bis 1 Mio EUR: 100% verrechenbar
// Über 1 Mio EUR: nur 60% verrechenbar (Mindestbesteuerung) // Ueber 1 Mio EUR: nur 60% verrechenbar (Mindestbesteuerung)
let verrechenbar = 0 let verrechenbar = 0
if (verlustvortrag > 0) { if (verlustvortrag > 0) {
if (gewinn <= 1000000) { if (gewinn <= 1000000) {

View File

@@ -1,8 +1,8 @@
/** /**
* Liquiditaet — rolling cash balance computation * Liquiditaet — rolling cash balance computation
* *
* Computes operative Einzahlungen/Auszahlungen sums, * Computes Einzahlungen/Auszahlungen sums (dynamic row_type-based),
* Überschuss vor Investitionen/Entnahmen, and rolling Kontostand. * Ueberschuss vor Investitionen/Entnahmen, and rolling Kontostand/Liquiditaet.
*/ */
import { Pool } from 'pg' import { Pool } from 'pg'
@@ -58,52 +58,57 @@ export async function computeLiquiditaet(
liqInvest.values = ctx.totalInvest liqInvest.values = ctx.totalInvest
} }
// Compute sums and rolling balance // Compute sums and rolling balance — dynamic row_type-based (handles any label conventions)
const sumEin = findLiq('Summe EINZAHLUNGEN') await computeRollingBalance(pool, liquid)
const sumAus = findLiq('Summe AUSZAHLUNGEN')
const uebVorInv = findLiq('ÜBERSCHUSS VOR INVESTITIONEN')
const uebVorEnt = findLiq('ÜBERSCHUSS VOR ENTNAHMEN')
const ueberschuss = findLiq('ÜBERSCHUSS')
const kontostand = findLiq('Kontostand zu Beginn des Monats')
const liquiditaet = findLiq('LIQUIDITÄT')
// Dynamically categorize rows by row_type return { endstand: liquid.find(r => r.row_type === 'kontostand' && r.row_label.includes('LIQUIDIT'))?.values || emptyMonthly() }
const einzahlungenOperativ = ['Umsatzerlöse', 'Sonst. betriebl. Erträge', 'Anzahlungen'] }
const finanzierungRows = liquid.filter(r =>
r.row_type === 'einzahlung' &&
!einzahlungenOperativ.includes(r.row_label) &&
!r.row_label.includes('Summe')
)
const auszahlungenOperativ = ['Materialaufwand', 'Personalkosten', 'Sonstige Kosten', 'Umsatzsteuer', 'Gewerbesteuer', 'Körperschaftsteuer']
const finanzAuszahlungRows = liquid.filter(r =>
r.row_type === 'auszahlung' &&
!auszahlungenOperativ.includes(r.row_label) &&
!r.row_label.includes('Summe')
)
// Summe EINZAHLUNGEN = nur operativ /**
* Recompute Summe AUSZAHLUNGEN -> UEBERSCHUSS chain -> rolling balance.
* Called both in initial pass and after tax values are written from GuV.
*/
export async function computeRollingBalance(
pool: Pool,
liquid: FPLiquiditaet[],
): Promise<void> {
const findLiqMatch = (options: string[]) => liquid.find(r => options.includes(r.row_label))
const sumEin = findLiqMatch(['Summe ERTRÄGE', 'Summe EINZAHLUNGEN'])
const sumAus = findLiqMatch(['Summe AUSZAHLUNGEN'])
const uebVorInv = findLiqMatch(['ÜBERSCHUSS VOR INVESTITIONEN', 'UEBERSCHUSS VOR INVESTITIONEN'])
const uebVorEnt = findLiqMatch(['ÜBERSCHUSS VOR ENTNAHMEN', 'UEBERSCHUSS VOR ENTNAHMEN'])
const ueberschuss = findLiqMatch(['ÜBERSCHUSS', 'UEBERSCHUSS'])
const liqInvest = liquid.find(r => r.row_label === 'Investitionen')
// Kontostand: label varies per scenario (with/without parentheses)
const kontostand = liquid.find(r => r.row_type === 'kontostand' && !r.row_label.includes('LIQUIDIT'))
const liquiditaet = liquid.find(r => r.row_type === 'kontostand' && r.row_label.includes('LIQUIDIT'))
// Summe ERTRAEGE = ALL einzahlungen (dynamic — works regardless of how many rows exist)
if (sumEin) { if (sumEin) {
const s = emptyMonthly() const s = emptyMonthly()
for (const label of einzahlungenOperativ) { for (const row of liquid) {
const row = findLiq(label) if (row.row_type === 'einzahlung' && row.id !== sumEin.id) {
if (row) for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += Math.round(row.values[`m${m}`] || 0) for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += Math.round(row.values[`m${m}`] || 0)
}
} }
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), sumEin.id]) await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), sumEin.id])
sumEin.values = s sumEin.values = s
} }
// Summe AUSZAHLUNGEN = nur operativ // Summe AUSZAHLUNGEN = ALL auszahlungen (dynamic)
if (sumAus) { if (sumAus) {
const s = emptyMonthly() const s = emptyMonthly()
for (const label of auszahlungenOperativ) { for (const row of liquid) {
const row = findLiq(label) if (row.row_type === 'auszahlung' && row.id !== sumAus.id) {
if (row) for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += Math.round(row.values[`m${m}`] || 0) for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += Math.round(row.values[`m${m}`] || 0)
}
} }
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), sumAus.id]) await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), sumAus.id])
sumAus.values = s sumAus.values = s
} }
// OPERATIVER ÜBERSCHUSS VOR INVESTITIONEN // UEBERSCHUSS VOR INVESTITIONEN = Summe ERTRAEGE - Summe AUSZAHLUNGEN (total cashflow)
if (uebVorInv && sumEin && sumAus) { if (uebVorInv && sumEin && sumAus) {
const s = emptyMonthly() const s = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((sumEin.values[`m${m}`] || 0) - (sumAus.values[`m${m}`] || 0)) for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((sumEin.values[`m${m}`] || 0) - (sumAus.values[`m${m}`] || 0))
@@ -111,7 +116,7 @@ export async function computeLiquiditaet(
uebVorInv.values = s uebVorInv.values = s
} }
// ÜBERSCHUSS VOR ENTNAHMEN = Operativer Überschuss - Investitionen // UEBERSCHUSS VOR ENTNAHMEN = UEBERSCHUSS VOR INVESTITIONEN - Investitionen
if (uebVorEnt && uebVorInv && liqInvest) { if (uebVorEnt && uebVorInv && liqInvest) {
const s = emptyMonthly() const s = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((uebVorInv.values[`m${m}`] || 0) - (liqInvest.values[`m${m}`] || 0)) for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((uebVorInv.values[`m${m}`] || 0) - (liqInvest.values[`m${m}`] || 0))
@@ -119,8 +124,8 @@ export async function computeLiquiditaet(
uebVorEnt.values = s uebVorEnt.values = s
} }
// ÜBERSCHUSS = Überschuss vor Entnahmen - Entnahmen // UEBERSCHUSS = UEBERSCHUSS VOR ENTNAHMEN - Kapitalentnahmen
const entnahmen = findLiq('Kapitalentnahmen/Ausschüttungen') const entnahmen = findLiqMatch(['Kapitalentnahmen/Ausschüttungen', 'Kapitalentnahmen/Ausschuettungen'])
if (ueberschuss && uebVorEnt && entnahmen) { if (ueberschuss && uebVorEnt && entnahmen) {
const s = emptyMonthly() const s = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((uebVorEnt.values[`m${m}`] || 0) - (entnahmen.values[`m${m}`] || 0)) for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((uebVorEnt.values[`m${m}`] || 0) - (entnahmen.values[`m${m}`] || 0))
@@ -128,27 +133,18 @@ export async function computeLiquiditaet(
ueberschuss.values = s ueberschuss.values = s
} }
// Rolling Kontostand: Vormonat + Operativer Überschuss + Finanzierung // Rolling balance: LIQUIDITAET[m] = LIQUIDITAET[m-1] + UEBERSCHUSS[m]
// UEBERSCHUSS now includes ALL cash flows (operative + financing + repayments)
if (kontostand && liquiditaet && ueberschuss) { if (kontostand && liquiditaet && ueberschuss) {
const finCF = emptyMonthly()
for (const row of finanzierungRows) {
for (let m = 1; m <= MONTHS; m++) finCF[`m${m}`] += Math.round(row.values[`m${m}`] || 0)
}
for (const row of finanzAuszahlungRows) {
for (let m = 1; m <= MONTHS; m++) finCF[`m${m}`] -= Math.round(row.values[`m${m}`] || 0)
}
const ks = emptyMonthly() const ks = emptyMonthly()
const lq = emptyMonthly() const lq = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) { for (let m = 1; m <= MONTHS; m++) {
ks[`m${m}`] = m === 1 ? 0 : Math.round(lq[`m${m - 1}`]) ks[`m${m}`] = m === 1 ? 0 : Math.round(lq[`m${m - 1}`])
lq[`m${m}`] = Math.round(ks[`m${m}`] + (ueberschuss.values[`m${m}`] || 0) + (finCF[`m${m}`] || 0)) lq[`m${m}`] = Math.round(ks[`m${m}`] + (ueberschuss.values[`m${m}`] || 0))
} }
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(ks), kontostand.id]) await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(ks), kontostand.id])
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(lq), liquiditaet.id]) await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(lq), liquiditaet.id])
kontostand.values = ks kontostand.values = ks
liquiditaet.values = lq liquiditaet.values = lq
} }
return { endstand: liquiditaet?.values || emptyMonthly() }
} }

View File

@@ -6,7 +6,7 @@
*/ */
import { import {
MonthlyValues, MONTHS, FOUNDING_MONTH, MonthlyValues, MONTHS,
emptyMonthly, dateToMonth, monthToDate, emptyMonthly, dateToMonth, monthToDate,
FPPersonalkosten, FPInvestitionen, FPPersonalkosten, FPInvestitionen,
} from './types' } from './types'

View File

@@ -4,40 +4,38 @@
* Dependency order: * Dependency order:
* Personalkosten (independent inputs) * Personalkosten (independent inputs)
* Investitionen (independent inputs) * Investitionen (independent inputs)
* Kunden Umsatzerlöse Materialaufwand * Kunden -> Umsatzerloese -> Materialaufwand
* Betriebliche Aufwendungen (needs Personal + Invest) * Betriebliche Aufwendungen (needs Personal + Invest)
* Sonst. betr. Erträge (independent) * Sonst. betr. Ertraege (independent)
* Liquidität (aggregates all above) * Liquiditaet (aggregates all above)
* GuV (annual summary) * GuV (annual summary)
* *
* Split into modules: * Each computation step is delegated to a companion module:
* engine-sheets.ts — pure calculators (no DB) * engine-sheets.ts — pure computation (Personal, Invest, aggregation)
* engine-betrieb.ts — betriebliche aufwendungen * engine-betrieb.ts — formula-based opex + category sums
* engine-liquiditaet.ts — liquidity / cash flow * engine-liquiditaet.ts — rolling cash balance
* engine-guv.ts — GuV / P&L + taxes * engine-guv.ts — annual P&L + taxes
*/ */
import { Pool } from 'pg' import { Pool } from 'pg'
import { import {
MonthlyValues, MONTHS, FOUNDING_MONTH, MonthlyValues, MONTHS, FOUNDING_MONTH,
emptyMonthly, emptyMonthly, FPBetrieblicheAufwendungen,
FPPersonalkosten, FPInvestitionen, FPBetrieblicheAufwendungen,
FPLiquiditaet, FPComputeResult, FPLiquiditaet, FPComputeResult,
} from './types' } from './types'
import { import { computePersonalkosten, computeInvestitionen, sumField } from './engine-sheets'
computePersonalkosten, computeInvestitionen, import { computeBetrieblicheAufwendungen, computeCloudHosting } from './engine-betrieb'
sumField, computeHeadcount, import { computeLiquiditaet, computeRollingBalance } from './engine-liquiditaet'
} from './engine-sheets'
import { computeBetrieblicheAufwendungen } from './engine-betrieb'
import { computeLiquiditaet } from './engine-liquiditaet'
import { computeGuV } from './engine-guv' import { computeGuV } from './engine-guv'
// Re-export sheet calculators for direct consumers // Re-export pure calculators for direct use by tests / scripts
export { computePersonalkosten, computeInvestitionen } from './engine-sheets' export { computePersonalkosten, computeInvestitionen } from './engine-sheets'
// Import types used inline // Import types used only inside this file
type FPUmsatzerloese = import('./types').FPUmsatzerloese type FPUmsatzerloese = import('./types').FPUmsatzerloese
type FPMaterialaufwand = import('./types').FPMaterialaufwand type FPMaterialaufwand = import('./types').FPMaterialaufwand
type FPPersonalkosten = import('./types').FPPersonalkosten
type FPInvestitionen = import('./types').FPInvestitionen
// --- Main Engine --- // --- Main Engine ---
@@ -61,8 +59,12 @@ export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise
const totalBrutto = sumField(personal as any, 'values_brutto') const totalBrutto = sumField(personal as any, 'values_brutto')
const totalSozial = sumField(personal as any, 'values_sozial') const totalSozial = sumField(personal as any, 'values_sozial')
const totalPersonal = sumField(personal as any, 'values_total') const totalPersonal = sumField(personal as any, 'values_total')
const headcount = computeHeadcount(personal) const headcount = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) {
headcount[`m${m}`] = personal.filter(p => (p.values_total[`m${m}`] || 0) > 0).length
}
// Write computed values back to DB
for (const p of personal) { for (const p of personal) {
await pool.query( await pool.query(
'UPDATE fp_personalkosten SET values_brutto = $1, values_sozial = $2, values_total = $3 WHERE id = $4', 'UPDATE fp_personalkosten SET values_brutto = $1, values_sozial = $2, values_total = $3 WHERE id = $4',
@@ -82,84 +84,70 @@ export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise
) )
} }
// 4. Umsatzerlöse (quantity × price) + Materialaufwand // 4. Umsatzerloese + Materialaufwand
const { totalRevenue, totalMaterial } = await computeRevenueAndMaterial( const { totalRevenue, totalMaterial } = await computeRevenueAndMaterial(
pool, umsatzRows.rows as FPUmsatzerloese[], materialRows.rows as FPMaterialaufwand[] pool, umsatzRows.rows as FPUmsatzerloese[], materialRows.rows as FPMaterialaufwand[],
) )
// 5. Bestandskunden (for formula-based costs) // 5. Bestandskunden (for formula-based costs)
const kundenRows = await pool.query( const totalBestandskunden = await loadBestandskunden(pool, scenarioId)
"SELECT segment_name, row_label, values FROM fp_kunden WHERE scenario_id = $1 AND row_label LIKE 'Bestandskunden%' ORDER BY sort_order",
[scenarioId]
)
const totalBestandskunden = emptyMonthly()
for (const row of kundenRows.rows) {
const rl = (row as { row_label?: string }).row_label || ''
if (rl.includes('Bestandskunden') && !rl.includes('gesamt')) {
for (let m = 1; m <= MONTHS; m++) {
totalBestandskunden[`m${m}`] += row.values?.[`m${m}`] || 0
}
}
}
// Cloud-Hosting in Materialaufwand // 5b. Cloud-Hosting formula in Materialaufwand
const matRows = materialRows.rows as FPMaterialaufwand[] await computeCloudHosting(pool, materialRows.rows as FPMaterialaufwand[], totalBestandskunden)
const cloudRow = matRows.find(r => r.row_label.includes('Cloud-Hosting'))
if (cloudRow) {
const computed = emptyMonthly()
for (let m = FOUNDING_MONTH; m <= MONTHS; m++) {
const kunden = totalBestandskunden[`m${m}`] || 0
const extraKunden = Math.max(0, kunden - 10)
computed[`m${m}`] = Math.round(extraKunden * 100 + 1500)
}
await pool.query('UPDATE fp_materialaufwand SET values = $1 WHERE id = $2', [JSON.stringify(computed), cloudRow.id])
cloudRow.values = computed
}
// 6. Betriebliche Aufwendungen // 6. Betriebliche Aufwendungen
const betrieb = betriebRows.rows as FPBetrieblicheAufwendungen[] const betrieb = betriebRows.rows as FPBetrieblicheAufwendungen[]
const { totalSonstige, totalGesamt } = await computeBetrieblicheAufwendungen(pool, betrieb, { const { totalSonstige } = await computeBetrieblicheAufwendungen(pool, betrieb, {
totalBrutto, totalPersonal, totalAfa, totalRevenue, totalMaterial, totalBrutto, totalPersonal, totalAfa, totalRevenue, totalMaterial,
headcount, totalBestandskunden, headcount, totalBestandskunden,
}) })
// 7. Liquidität // 7. Liquiditaet (first pass)
const liquid = liquidRows.rows as FPLiquiditaet[] const liquid = liquidRows.rows as FPLiquiditaet[]
const { endstand } = await computeLiquiditaet(pool, liquid, { await computeLiquiditaet(pool, liquid, {
totalRevenue, totalMaterial, totalPersonal, totalSonstige, totalInvest, totalRevenue, totalMaterial, totalPersonal, totalSonstige, totalInvest,
}) })
// 8. GuV // 8. GuV — compute annual values + taxes (writes tax rows to Liquiditaet)
const guv = await computeGuV(pool, scenarioId, liquid, { const guv = await computeGuV(pool, scenarioId, liquid, {
totalRevenue, totalMaterial, totalBrutto, totalSozial, totalRevenue, totalMaterial, totalBrutto, totalSozial,
totalPersonal, totalAfa, totalSonstige, totalPersonal, totalAfa, totalSonstige,
}) })
// 9. Second pass: tax rows were written after rolling balance above.
// Recompute Summe AUSZAHLUNGEN -> UEBERSCHUSS chain -> rolling balance
// so taxes are included even on the first engine run.
await computeRollingBalance(pool, liquid)
// 10. Build result
const gesamtBetrieb = betrieb.find(r => r.row_label.includes('Gesamtkosten') || r.row_label.includes('SUMME Betriebliche'))
const liquiditaetRow = liquid.find(r => r.row_type === 'kontostand' && r.row_label.includes('LIQUIDIT'))
return { return {
personalkosten: { total_brutto: totalBrutto, total_sozial: totalSozial, total: totalPersonal, positions: personal, headcount }, personalkosten: { total_brutto: totalBrutto, total_sozial: totalSozial, total: totalPersonal, positions: personal, headcount },
investitionen: { total_invest: totalInvest, total_afa: totalAfa, items: invest }, investitionen: { total_invest: totalInvest, total_afa: totalAfa, items: invest },
umsatzerloese: { total: totalRevenue }, umsatzerloese: { total: totalRevenue },
materialaufwand: { total: totalMaterial }, materialaufwand: { total: totalMaterial },
betriebliche: { total_sonstige: totalSonstige, total_gesamt: totalGesamt }, betriebliche: { total_sonstige: totalSonstige, total_gesamt: gesamtBetrieb?.values || emptyMonthly() },
liquiditaet: { rows: liquid, endstand }, liquiditaet: { rows: liquid, endstand: liquiditaetRow?.values || emptyMonthly() },
guv, guv,
} }
} }
// --- Revenue & Material helpers (kept here to avoid circular deps) --- // --- Revenue & Material helpers (kept in orchestrator — tightly coupled to DB writes) ---
async function computeRevenueAndMaterial( async function computeRevenueAndMaterial(
pool: Pool, pool: Pool,
umsatzAllRows: FPUmsatzerloese[], umsatzAllRows: FPUmsatzerloese[],
materialAllRows: FPMaterialaufwand[], materialAllRows: FPMaterialaufwand[],
): Promise<{ totalRevenue: MonthlyValues; totalMaterial: MonthlyValues }> { ): Promise<{ totalRevenue: MonthlyValues; totalMaterial: MonthlyValues }> {
// Umsatzerloese (quantity x price)
const prices = umsatzAllRows.filter(r => r.section === 'price') const prices = umsatzAllRows.filter(r => r.section === 'price')
const quantities = umsatzAllRows.filter(r => r.section === 'quantity') const quantities = umsatzAllRows.filter(r => r.section === 'quantity')
const revenueRows = umsatzAllRows.filter(r => r.section === 'revenue') const revenueRows = umsatzAllRows.filter(r => r.section === 'revenue')
const totalRevenue = emptyMonthly() const totalRevenue = emptyMonthly()
const extractTier = (label: string) => { const m = label.match(/\(([^)]+)\)/); return m ? m[1] : label } const extractTier = (label: string) => { const m = label.match(/\(([^)]+)\)/); return m ? m[1] : label }
for (const rev of revenueRows) { for (const rev of revenueRows) {
if (rev.row_label === 'GESAMTUMSATZ') continue if (rev.row_label === 'GESAMTUMSATZ') continue
const tier = extractTier(rev.row_label) const tier = extractTier(rev.row_label)
@@ -167,8 +155,7 @@ async function computeRevenueAndMaterial(
const price = prices.find(p => extractTier(p.row_label) === tier) || prices.find(p => p.row_label === rev.row_label) const price = prices.find(p => extractTier(p.row_label) === tier) || prices.find(p => p.row_label === rev.row_label)
if (qty && price) { if (qty && price) {
for (let m = 1; m <= MONTHS; m++) { for (let m = 1; m <= MONTHS; m++) {
const v = (qty.values[`m${m}`] || 0) * (price.values[`m${m}`] || 0) rev.values[`m${m}`] = Math.round((qty.values[`m${m}`] || 0) * (price.values[`m${m}`] || 0))
rev.values[`m${m}`] = Math.round(v)
} }
await pool.query('UPDATE fp_umsatzerloese SET values = $1 WHERE id = $2', [JSON.stringify(rev.values), rev.id]) await pool.query('UPDATE fp_umsatzerloese SET values = $1 WHERE id = $2', [JSON.stringify(rev.values), rev.id])
} }
@@ -181,7 +168,7 @@ async function computeRevenueAndMaterial(
await pool.query('UPDATE fp_umsatzerloese SET values = $1 WHERE id = $2', [JSON.stringify(totalRevenue), gesamtUmsatz.id]) await pool.query('UPDATE fp_umsatzerloese SET values = $1 WHERE id = $2', [JSON.stringify(totalRevenue), gesamtUmsatz.id])
} }
// Materialaufwand // Materialaufwand (quantity x unit_cost)
const matCosts = materialAllRows.filter(r => r.section === 'cost') const matCosts = materialAllRows.filter(r => r.section === 'cost')
const matUnitCosts = materialAllRows.filter(r => r.section === 'unit_cost') const matUnitCosts = materialAllRows.filter(r => r.section === 'unit_cost')
const totalMaterial = emptyMonthly() const totalMaterial = emptyMonthly()
@@ -192,8 +179,7 @@ async function computeRevenueAndMaterial(
const qty = quantities.find(q => q.row_label === cost.row_label) const qty = quantities.find(q => q.row_label === cost.row_label)
if (uc && qty) { if (uc && qty) {
for (let m = 1; m <= MONTHS; m++) { for (let m = 1; m <= MONTHS; m++) {
const v = (qty.values[`m${m}`] || 0) * (uc.values[`m${m}`] || 0) cost.values[`m${m}`] = Math.round((qty.values[`m${m}`] || 0) * (uc.values[`m${m}`] || 0))
cost.values[`m${m}`] = Math.round(v)
} }
await pool.query('UPDATE fp_materialaufwand SET values = $1 WHERE id = $2', [JSON.stringify(cost.values), cost.id]) await pool.query('UPDATE fp_materialaufwand SET values = $1 WHERE id = $2', [JSON.stringify(cost.values), cost.id])
} }
@@ -208,3 +194,20 @@ async function computeRevenueAndMaterial(
return { totalRevenue, totalMaterial } return { totalRevenue, totalMaterial }
} }
async function loadBestandskunden(pool: Pool, scenarioId: string): Promise<MonthlyValues> {
const kundenRows = await pool.query(
"SELECT segment_name, row_label, values FROM fp_kunden WHERE scenario_id = $1 AND row_label LIKE 'Bestandskunden%' ORDER BY sort_order",
[scenarioId]
)
const totalBestandskunden = emptyMonthly()
for (const row of kundenRows.rows) {
const rl = (row as { row_label?: string }).row_label || ''
if (rl.includes('Bestandskunden') && !rl.includes('gesamt')) {
for (let m = 1; m <= MONTHS; m++) {
totalBestandskunden[`m${m}`] += row.values?.[`m${m}`] || 0
}
}
}
return totalBestandskunden
}

View File

@@ -6,7 +6,6 @@ const PUBLIC_PATHS = [
'/auth', // investor login pages '/auth', // investor login pages
'/api/auth', // investor auth API '/api/auth', // investor auth API
'/api/health', '/api/health',
'/api/admin/fp-patch',
'/api/admin-auth', // admin login API '/api/admin-auth', // admin login API
'/pitch-admin/login', // admin login page '/pitch-admin/login', // admin login page
'/_next', '/_next',
@@ -47,10 +46,14 @@ export async function middleware(request: NextRequest) {
// ----- Admin-gated routes ----- // ----- Admin-gated routes -----
if (isAdminGatedPath(pathname)) { if (isAdminGatedPath(pathname)) {
// Allow legacy bearer-secret CLI access on /api/admin/* (the API routes themselves // Allow bearer-secret CLI access on /api/admin/* — validate the token here,
// also check this and log as actor='cli'). The bearer header is opaque to the JWT // not just in the route handler, to avoid any unprotected route slipping through.
// path, so we just let it through here and let the route handler enforce.
if (pathname.startsWith('/api/admin') && request.headers.get('authorization')?.startsWith('Bearer ')) { if (pathname.startsWith('/api/admin') && request.headers.get('authorization')?.startsWith('Bearer ')) {
const bearerToken = request.headers.get('authorization')!.slice(7)
const adminSecret = process.env.PITCH_ADMIN_SECRET
if (!adminSecret || bearerToken !== adminSecret) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return NextResponse.next() return NextResponse.next()
} }

View File

@@ -5,9 +5,6 @@ const nextConfig = {
NEXT_PUBLIC_GIT_SHA: process.env.GIT_SHA || 'dev', NEXT_PUBLIC_GIT_SHA: process.env.GIT_SHA || 'dev',
}, },
reactStrictMode: true, reactStrictMode: true,
typescript: {
ignoreBuildErrors: true,
},
serverExternalPackages: ['nodemailer'], serverExternalPackages: ['nodemailer'],
async headers() { async headers() {
return [ return [

View File

@@ -14,7 +14,7 @@
"incremental": true, "incremental": true,
"plugins": [{ "name": "next" }], "plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }, "paths": { "@/*": ["./*"] },
"target": "ES2017" "target": "ES2018"
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]