diff --git a/pitch-deck/app/api/chat/route.ts b/pitch-deck/app/api/chat/route.ts index 5709156..f274842 100644 --- a/pitch-deck/app/api/chat/route.ts +++ b/pitch-deck/app/api/chat/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import pool from '@/lib/db' +import { getSessionFromCookie } from '@/lib/auth' import { SLIDE_ORDER } from '@/lib/slide-order' const LITELLM_URL = process.env.LITELLM_URL || 'https://llm-dev.meghsakha.com' @@ -34,7 +35,8 @@ const SLIDE_DISPLAY_NAMES: Record = { 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 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." 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." -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." +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."` + +// Static middle: Kommunikationsstil — injected between #9 and VERSIONS-ISOLATION +const SYSTEM_PROMPT_PART2 = ` ## Kommunikationsstil - 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..." - 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 -- 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) -- Du kennst NUR die Wandeldarlehen-Version mit 200.000 EUR Finanzierung. -- 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. +// Static suffix: everything after VERSIONS-ISOLATION +const SYSTEM_PROMPT_PART3 = ` ## IP-Schutz-Layer (KRITISCH) 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: -"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? @@ -115,8 +115,125 @@ KONKRETES BEISPIEL einer vollständigen Antwort: WICHTIG: Vergiss NIEMALS die Folgefragen! Sie sind PFLICHT.` -async function loadPitchContext(): Promise { +async function loadFpLiquiditaetSummary(scenarioName: string): Promise { 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> = {} + 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 | null, + financials: Array> +): 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 { + 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 = {} + 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>) ?? [] + const market = (map.market as unknown[]) ?? [] + const products = (map.products as unknown[]) ?? [] + const funding = ((map.funding as unknown[])?.[0] as Record) ?? null + const features = ((map.features as Array>) ?? []) + .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() try { const [company, team, financials, market, products, funding, features] = await Promise.all([ @@ -128,59 +245,117 @@ async function loadPitchContext(): Promise { 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 (für präzise 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)} -` + const meta = extractMeta('', undefined, funding.rows[0] ?? null, financials.rows) + return { + contextString: buildContextString( + company.rows[0], team.rows, financials.rows, market.rows, + products.rows, funding.rows[0], features.rows, '' + ), + meta, + } } finally { client.release() } } catch (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) { try { 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') { 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 - if (pitchContext) { - systemContent += '\n' + pitchContext + const { contextString, meta } = await loadPitchContext(versionId) + + // 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 + // 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') { 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 @@ -236,7 +411,7 @@ export async function POST(request: NextRequest) { if (!llmResponse.ok) { 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( { error: `LLM nicht erreichbar (Status ${llmResponse.status}).` }, { status: 502 } @@ -295,7 +470,7 @@ export async function POST(request: NextRequest) { } } } catch (error) { - console.error('Stream read error:', error) + console.error('Stream read error:', (error as Error).message) } finally { controller.close() } @@ -310,7 +485,7 @@ export async function POST(request: NextRequest) { }, }) } catch (error) { - console.error('Investor agent chat error:', error) + console.error('Investor agent chat error:', (error as Error).message) return NextResponse.json( { error: 'Verbindung zum LLM fehlgeschlagen.' }, { status: 503 } diff --git a/pitch-deck/app/api/data/route.ts b/pitch-deck/app/api/data/route.ts index 58899d9..cffd15a 100644 --- a/pitch-deck/app/api/data/route.ts +++ b/pitch-deck/app/api/data/route.ts @@ -77,7 +77,7 @@ export async function GET() { client.release() } } 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 if (process.env.NODE_ENV === 'development') { return NextResponse.json({ diff --git a/pitch-deck/app/api/financial-model/assumptions/route.ts b/pitch-deck/app/api/financial-model/assumptions/route.ts index 3ac8d35..d34d5c6 100644 --- a/pitch-deck/app/api/financial-model/assumptions/route.ts +++ b/pitch-deck/app/api/financial-model/assumptions/route.ts @@ -1,8 +1,12 @@ import { NextRequest, NextResponse } from 'next/server' 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) { + if (!validateAdminSecret(request)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } try { const body = await request.json() const { scenarioId, key, value } = body diff --git a/pitch-deck/app/api/financial-model/route.ts b/pitch-deck/app/api/financial-model/route.ts index 4dc7f30..7e971e5 100644 --- a/pitch-deck/app/api/financial-model/route.ts +++ b/pitch-deck/app/api/financial-model/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import pool from '@/lib/db' -import { getSessionFromCookie } from '@/lib/auth' +import { getSessionFromCookie, validateAdminSecret } from '@/lib/auth' 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) { + if (!validateAdminSecret(request)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } try { const body = await request.json() const { name, description, color, copyFrom } = body diff --git a/pitch-deck/app/api/finanzplan/[sheetName]/route.ts b/pitch-deck/app/api/finanzplan/[sheetName]/route.ts index 6a32155..6893af9 100644 --- a/pitch-deck/app/api/finanzplan/[sheetName]/route.ts +++ b/pitch-deck/app/api/finanzplan/[sheetName]/route.ts @@ -14,6 +14,30 @@ const TABLE_MAP: Record = { guv: 'fp_guv', } +// Whitelist of scalar columns that may be edited per table +const SCALAR_COLUMNS_WHITELIST: Record = { + 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( request: NextRequest, { params }: { params: Promise<{ sheetName: string }> } @@ -24,7 +48,9 @@ export async function GET( 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 { let query = `SELECT * FROM ${table}` @@ -42,8 +68,8 @@ export async function GET( return NextResponse.json({ sheet: sheetName, rows }, { headers: { 'Cache-Control': 'no-store' }, }) - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }) + } catch { + return NextResponse.json({ error: 'Query failed' }, { status: 500 }) } } @@ -51,6 +77,11 @@ export async function PUT( request: NextRequest, { 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 table = TABLE_MAP[sheetName] if (!table) { @@ -59,33 +90,47 @@ export async function PUT( try { 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 }) } + 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 - const monthlyKeys = Object.keys(updates).filter(k => k.startsWith('m') && !isNaN(parseInt(k.substring(1)))) - const scalarKeys = Object.keys(updates).filter(k => !k.startsWith('m') || isNaN(parseInt(k.substring(1)))) + // C1: Separate and validate monthly vs scalar keys + const monthlyKeys = Object.keys(updates).filter(k => MONTH_KEY_RE.test(k)) + 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) { - // Update specific months in the values JSONB - const jsonbSet = monthlyKeys.map(k => `'${k}', '${updates[k]}'::jsonb`).join(', ') const valuesCol = sheetName === 'personalkosten' ? 'values_brutto' : 'values' - // Use jsonb_set for each key - let updateSql = `UPDATE ${table} SET ` - const setClauses: string[] = [] + // Build sanitized JSON patch object — no interpolation of user data into SQL + const patch: Record = {} for (const k of monthlyKeys) { - setClauses.push(`${valuesCol} = jsonb_set(${valuesCol}, '{${k}}', '${updates[k]}')`) + patch[k] = Number(updates[k]) } - setClauses.push(`updated_at = NOW()`) - updateSql += setClauses.join(', ') + ` WHERE id = $1` - await pool.query(updateSql, [rowId]) + await pool.query( + `UPDATE ${table} SET ${valuesCol} = ${valuesCol} || $1::jsonb, updated_at = NOW() WHERE id = $2`, + [JSON.stringify(patch), rowId] + ) } 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(', ') await pool.query( `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]) return NextResponse.json({ updated: rows[0] }) - } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 500 }) + } catch { + return NextResponse.json({ error: 'Update failed' }, { status: 500 }) } } diff --git a/pitch-deck/app/api/finanzplan/compute/route.ts b/pitch-deck/app/api/finanzplan/compute/route.ts index 1e6df56..149cf44 100644 --- a/pitch-deck/app/api/finanzplan/compute/route.ts +++ b/pitch-deck/app/api/finanzplan/compute/route.ts @@ -1,8 +1,12 @@ import { NextRequest, NextResponse } from 'next/server' import pool from '@/lib/db' import { computeFinanzplan } from '@/lib/finanzplan/engine' +import { validateAdminSecret } from '@/lib/auth' export async function POST(request: NextRequest) { + if (!validateAdminSecret(request)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } try { const body = await request.json().catch(() => ({})) const scenarioId = body.scenarioId @@ -33,6 +37,6 @@ export async function POST(request: NextRequest) { }) } catch (error) { console.error('Finanzplan compute error:', error) - return NextResponse.json({ error: String(error) }, { status: 500 }) + return NextResponse.json({ error: 'Compute failed' }, { status: 500 }) } } diff --git a/pitch-deck/app/api/finanzplan/route.ts b/pitch-deck/app/api/finanzplan/route.ts index 953e6c7..f975aae 100644 --- a/pitch-deck/app/api/finanzplan/route.ts +++ b/pitch-deck/app/api/finanzplan/route.ts @@ -1,10 +1,18 @@ -import { NextResponse } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' import pool from '@/lib/db' 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 { - 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 const sheets = await Promise.all( diff --git a/pitch-deck/components/slides/MilestonesSlide.data.ts b/pitch-deck/components/slides/MilestonesSlide.data.ts index 2468d07..0ff4f4d 100644 --- a/pitch-deck/components/slides/MilestonesSlide.data.ts +++ b/pitch-deck/components/slides/MilestonesSlide.data.ts @@ -1,4 +1,5 @@ // MilestonesSlide data — extracted from MilestonesSlide.tsx +// Contains: Milestone/StatItem interfaces, MILESTONES array, STATS array, TODAY_POSITION 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[] = [ { - 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' }, short: { de: 'Abstimmung mit Agentur f\u00fcr Arbeit und IHK Konstanz.', en: 'Coordination with Employment Agency and IHK Konstanz.' }, body: { @@ -33,7 +35,8 @@ export const MILESTONES: Milestone[] = [ 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' }, short: { de: 'DPMA-Anmeldung BreakPilot + Domain-Portfolio.', en: 'DPMA filing BreakPilot + domain portfolio.' }, body: { @@ -47,7 +50,8 @@ export const MILESTONES: Milestone[] = [ 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' }, short: { de: '500.000+ Lines of Code, vollst\u00e4ndige Architektur.', en: '500,000+ lines of code, full architecture.' }, body: { @@ -61,7 +65,8 @@ export const MILESTONES: Milestone[] = [ 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' }, short: { de: 'BreakPilot offiziell eingetragen.', en: 'BreakPilot officially registered.' }, body: { @@ -75,7 +80,8 @@ export const MILESTONES: Milestone[] = [ 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' }, short: { de: 'EU + DACH Regularien indexiert.', en: 'EU + DACH regulations indexed.' }, body: { @@ -89,7 +95,8 @@ export const MILESTONES: Milestone[] = [ 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' }, short: { de: 'EU-weiter Markenschutz beantragt.', en: 'EU-wide trademark protection filed.' }, body: { @@ -103,7 +110,8 @@ export const MILESTONES: Milestone[] = [ 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' }, short: { de: 'Breakpilot COMPLAI GmbH gegr\u00fcndet.', en: 'Breakpilot COMPLAI GmbH incorporated.' }, body: { @@ -117,7 +125,8 @@ export const MILESTONES: Milestone[] = [ 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' }, short: { de: 'Erste Ums\u00e4tze ab Gr\u00fcndung.', en: 'First revenue from incorporation.' }, body: { @@ -131,7 +140,8 @@ export const MILESTONES: Milestone[] = [ 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' }, short: { de: 'Beta-Launch mit ersten zahlenden Kunden.', en: 'Beta launch with first paying customers.' }, body: { diff --git a/pitch-deck/components/slides/MilestonesSlide.parts.tsx b/pitch-deck/components/slides/MilestonesSlide.parts.tsx index 7f795f3..a1db13a 100644 --- a/pitch-deck/components/slides/MilestonesSlide.parts.tsx +++ b/pitch-deck/components/slides/MilestonesSlide.parts.tsx @@ -1,13 +1,8 @@ 'use client' import { useState, useEffect, useMemo } from 'react' -import { type Milestone, type StatItem, MILESTONES, TODAY_POSITION } from './MilestonesSlide.data' -import { type Theme } from './MilestonesSlide.themes' - -const MONO: React.CSSProperties = { - fontFamily: '"JetBrains Mono","SF Mono",ui-monospace,monospace', - fontVariantNumeric: 'tabular-nums', -} +import { type Milestone, type StatItem, MILESTONES, STATS, TODAY_POSITION } from './MilestonesSlide.data' +import { type Theme, MONO } from './MilestonesSlide.themes' // ── Star Field ──────────────────────────────────────────────────────────────── 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 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 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 ( <> @@ -175,7 +170,7 @@ function MilestoneNode({ m, onClick, active, t, de }: { transition: 'all .25s', transform: lit ? 'scale(1.15)' : 'scale(1)', }}> - {m.done ? '✓' : (m.next ? '◉' : '○')} + {m.done ? '\u2713' : (m.next ? '\u25c9' : '\u25cb')} {/* card */} @@ -224,7 +219,7 @@ function MilestoneNode({ m, onClick, active, t, de }: { opacity: lit ? 1 : 0.55, transform: `translateX(${lit ? 0 : -4}px)`, transition: 'all .25s', - }}>{de ? 'Details →' : 'Details →'} + }}>{de ? 'Details \u2192' : 'Details \u2192'} @@ -292,7 +287,7 @@ export function DetailModal({ item, onClose, t, de }: { const tint = item.tint const badge = item.done ? (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 return ( @@ -319,7 +314,7 @@ export function DetailModal({ item, onClose, t, de }: { display: 'flex', alignItems: 'center', justifyContent: 'center', color: t.key === 'light' ? tint : '#fff', fontSize: 17, fontWeight: 700, boxShadow: `0 0 20px ${tint}66`, - }}>{item.done ? '✓' : (item.next ? '◉' : '○')} + }}>{item.done ? '\u2713' : (item.next ? '\u25c9' : '\u25cb')}
✕ + }}>{'\u2715'}
{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`, }}> - {item.done ? '✓' : '▸'} + {item.done ? '\u2713' : '\u25b8'} {b}
@@ -360,3 +355,87 @@ export function DetailModal({ item, onClose, t, de }: {
) } + +// ── 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 ( +
+ {/* Ambient glow */} +
+ + {t.stars ? : } + + {/* Progress indicator */} +
+
+ {de ? 'Fortschritt' : 'Progress'} +
+
+
+
+
+ {doneCnt} + / {total} +
+
+ + {/* Tip */} +
+ {de ? 'Tipp:' : 'Tip:'} + {de ? 'Klick auf einen Meilenstein' : 'Click any milestone'} +
+ + {/* Timeline */} +
+ +
+ + {/* Stats */} +
+ {STATS.map(s => )} +
+ + {/* Footer */} +
+ {de ? 'Stand heute \u00b7 live-Metriken aus der Plattform' : 'As of today \u00b7 live metrics from the platform'} +
+ + setSel(null)} t={t} de={de} /> +
+ ) +} diff --git a/pitch-deck/components/slides/MilestonesSlide.themes.ts b/pitch-deck/components/slides/MilestonesSlide.themes.ts index 17b5caf..2ed4599 100644 --- a/pitch-deck/components/slides/MilestonesSlide.themes.ts +++ b/pitch-deck/components/slides/MilestonesSlide.themes.ts @@ -1,4 +1,42 @@ // 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 = { dark: { @@ -103,4 +141,4 @@ export const THEMES = { }, } -export type Theme = typeof THEMES.dark +export type Theme = typeof THEMES.dark | typeof THEMES.light diff --git a/pitch-deck/components/slides/MilestonesSlide.tsx b/pitch-deck/components/slides/MilestonesSlide.tsx index d8bd61f..03a865b 100644 --- a/pitch-deck/components/slides/MilestonesSlide.tsx +++ b/pitch-deck/components/slides/MilestonesSlide.tsx @@ -1,137 +1,15 @@ 'use client' -import { useState, useEffect, useRef, useMemo, useCallback } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { Language } from '@/lib/types' import GradientText from '../ui/GradientText' import FadeInView from '../ui/FadeInView' - -import { type Milestone, MILESTONES, STATS } from './MilestonesSlide.data' -import { THEMES } from './MilestonesSlide.themes' -import { StarField, SoftGrid, Timeline, StatCard, DetailModal } from './MilestonesSlide.parts' +import { type Milestone } from './MilestonesSlide.data' +import { THEMES, CSS_KF, useIsLight } from './MilestonesSlide.themes' +import { MilestonesInner } from './MilestonesSlide.parts' 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 ( -
- {/* Ambient glow */} -
- - {t.stars ? : } - - {/* Progress indicator */} -
-
- {de ? 'Fortschritt' : 'Progress'} -
-
-
-
-
- {doneCnt} - / {total} -
-
- - {/* Tip */} -
- {de ? 'Tipp:' : 'Tip:'} - {de ? 'Klick auf einen Meilenstein' : 'Click any milestone'} -
- - {/* Timeline */} -
- -
- - {/* Stats */} -
- {STATS.map(s => )} -
- - {/* Footer */} -
- {de ? 'Stand heute · live-Metriken aus der Plattform' : 'As of today · live metrics from the platform'} -
- - setSel(null)} t={t} de={de} /> -
- ) -} - -// ── Main slide ──────────────────────────────────────────────────────────────── const INNER_W = 1280 const INNER_H = 600 diff --git a/pitch-deck/lib/db.ts b/pitch-deck/lib/db.ts index bce424a..02a3e4b 100644 --- a/pitch-deck/lib/db.ts +++ b/pitch-deck/lib/db.ts @@ -8,7 +8,7 @@ import { Pool, types } from 'pg' types.setTypeParser(types.builtins.NUMERIC, (val) => (val === null ? null : parseFloat(val))) const pool = new Pool({ - connectionString: process.env.DATABASE_URL || 'postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_db', + connectionString: process.env.DATABASE_URL, max: 20, idleTimeoutMillis: 30000, connectionTimeoutMillis: 10000, diff --git a/pitch-deck/lib/finanzplan/adapter.ts b/pitch-deck/lib/finanzplan/adapter.ts index 6eee954..ca9c3b5 100644 --- a/pitch-deck/lib/finanzplan/adapter.ts +++ b/pitch-deck/lib/finanzplan/adapter.ts @@ -53,8 +53,8 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr const afaRow = betrieb.find((r: any) => r.row_label === 'Abschreibungen') const afa = afaRow?.values || emptyMonthly() - // Liquidität endstand - const liqEndRow = liquid.find((r: any) => r.row_label === 'LIQUIDITAET') + // Liquidität endstand — match by row_type to handle both 'LIQUIDITÄT' and 'LIQUIDITAET' labels + const liqEndRow = liquid.find((r: any) => r.row_type === 'kontostand' && r.row_label?.includes('LIQUIDIT')) const cashBalance = liqEndRow?.values || emptyMonthly() // 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) return { - scenario_id: sid, + scenario_id: sid as string, results, summary: { final_arr: lastMonth?.arr_eur || 0, diff --git a/pitch-deck/lib/finanzplan/engine-betrieb.ts b/pitch-deck/lib/finanzplan/engine-betrieb.ts index 8b51af8..5d6f2c7 100644 --- a/pitch-deck/lib/finanzplan/engine-betrieb.ts +++ b/pitch-deck/lib/finanzplan/engine-betrieb.ts @@ -2,7 +2,8 @@ * Betriebliche Aufwendungen — formula-based rows + category sums * * 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' @@ -13,6 +14,8 @@ import { } from './types' import { sumRows } from './engine-sheets' +type FPMaterialaufwand = import('./types').FPMaterialaufwand + export interface BetriebContext { totalBrutto: MonthlyValues totalPersonal: MonthlyValues @@ -23,6 +26,27 @@ export interface BetriebContext { totalBestandskunden: MonthlyValues } +/** + * Compute Cloud-Hosting formula in Materialaufwand. + */ +export async function computeCloudHosting( + pool: Pool, + matRows: FPMaterialaufwand[], + totalBestandskunden: MonthlyValues, +): Promise { + 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. * Writes computed values back to DB. @@ -41,6 +65,7 @@ export async function computeBetrieblicheAufwendungen( // Formula-based rows: derive from headcount (excl. founders) or customers const formulaRows: { label: string; perUnit: number; source: MonthlyValues }[] = [ { 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: 'Bewirtungskosten (F)', perUnit: 50, source: ctx.totalBestandskunden }, { 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')) if (bgRow) { const computed = emptyMonthly() @@ -95,6 +120,7 @@ export async function computeBetrieblicheAufwendungen( } // 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')) if (gewStRow) { const nonTaxOpex = betrieb.filter(r => diff --git a/pitch-deck/lib/finanzplan/engine-guv.ts b/pitch-deck/lib/finanzplan/engine-guv.ts index 4910636..cf1c38e 100644 --- a/pitch-deck/lib/finanzplan/engine-guv.ts +++ b/pitch-deck/lib/finanzplan/engine-guv.ts @@ -1,8 +1,8 @@ /** * GuV (Gewinn- und Verlustrechnung) — annual P&L computation * - * Computes annual sums, EBIT, taxes (Gewerbesteuer, Körperschaftsteuer) - * with Verlustvortrag, and writes tax amounts back to Liquidität. + * Computes annual sums, EBIT, taxes (Gewerbesteuer, Koerperschaftsteuer) + * with Verlustvortrag, and writes tax amounts back to Liquiditaet. */ 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. */ 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, '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('Körperschaftsteuer'), koerperschaftsteuer) @@ -95,8 +95,8 @@ export async function computeGuV( // --- Tax helpers --- // Stockach 78333: Hebesatz 350% -// Gewerbesteuer = 3,5% × 3,5 = 12,25% -// Körperschaftsteuer = 15% + 5,5% Soli = 15,825% +// Gewerbesteuer = 3,5% x 3,5 = 12,25% +// Koerperschaftsteuer = 15% + 5,5% Soli = 15,825% const GEWERBESTEUER_RATE = 0.035 * 3.5 // 12,25% const KOERPERSCHAFTSTEUER_RATE = 0.15 * 1.055 // 15,825% (inkl. Soli) @@ -112,14 +112,16 @@ function computeTaxes(ebit: AnnualValues) { const gewinn = ebit[k] || 0 if (gewinn <= 0) { + // Verlust: keine Steuern, Verlustvortrag aufbauen verlustvortrag += Math.abs(gewinn) gewerbesteuer[k] = 0 koerperschaftsteuer[k] = 0 steuernGesamt[k] = 0 ergebnisNachSteuern[k] = Math.round(gewinn) } else { + // Gewinn: Verlustvortrag verrechnen // 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 if (verlustvortrag > 0) { if (gewinn <= 1000000) { diff --git a/pitch-deck/lib/finanzplan/engine-liquiditaet.ts b/pitch-deck/lib/finanzplan/engine-liquiditaet.ts index 0e23d95..f33e3b0 100644 --- a/pitch-deck/lib/finanzplan/engine-liquiditaet.ts +++ b/pitch-deck/lib/finanzplan/engine-liquiditaet.ts @@ -1,8 +1,8 @@ /** * Liquiditaet — rolling cash balance computation * - * Computes operative Einzahlungen/Auszahlungen sums, - * Überschuss vor Investitionen/Entnahmen, and rolling Kontostand. + * Computes Einzahlungen/Auszahlungen sums (dynamic row_type-based), + * Ueberschuss vor Investitionen/Entnahmen, and rolling Kontostand/Liquiditaet. */ import { Pool } from 'pg' @@ -58,52 +58,57 @@ export async function computeLiquiditaet( liqInvest.values = ctx.totalInvest } - // Compute sums and rolling balance - const sumEin = findLiq('Summe EINZAHLUNGEN') - 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') + // Compute sums and rolling balance — dynamic row_type-based (handles any label conventions) + await computeRollingBalance(pool, liquid) - // Dynamically categorize rows by row_type - 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') - ) + return { endstand: liquid.find(r => r.row_type === 'kontostand' && r.row_label.includes('LIQUIDIT'))?.values || emptyMonthly() } +} - // 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 { + 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) { const s = emptyMonthly() - for (const label of einzahlungenOperativ) { - const row = findLiq(label) - if (row) for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += Math.round(row.values[`m${m}`] || 0) + for (const row of liquid) { + if (row.row_type === 'einzahlung' && row.id !== sumEin.id) { + 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]) sumEin.values = s } - // Summe AUSZAHLUNGEN = nur operativ + // Summe AUSZAHLUNGEN = ALL auszahlungen (dynamic) if (sumAus) { const s = emptyMonthly() - for (const label of auszahlungenOperativ) { - const row = findLiq(label) - if (row) for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += Math.round(row.values[`m${m}`] || 0) + for (const row of liquid) { + if (row.row_type === 'auszahlung' && row.id !== sumAus.id) { + 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]) sumAus.values = s } - // OPERATIVER ÜBERSCHUSS VOR INVESTITIONEN + // UEBERSCHUSS VOR INVESTITIONEN = Summe ERTRAEGE - Summe AUSZAHLUNGEN (total cashflow) if (uebVorInv && sumEin && sumAus) { 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)) @@ -111,7 +116,7 @@ export async function computeLiquiditaet( uebVorInv.values = s } - // ÜBERSCHUSS VOR ENTNAHMEN = Operativer Überschuss - Investitionen + // UEBERSCHUSS VOR ENTNAHMEN = UEBERSCHUSS VOR INVESTITIONEN - Investitionen if (uebVorEnt && uebVorInv && liqInvest) { 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)) @@ -119,8 +124,8 @@ export async function computeLiquiditaet( uebVorEnt.values = s } - // ÜBERSCHUSS = Überschuss vor Entnahmen - Entnahmen - const entnahmen = findLiq('Kapitalentnahmen/Ausschüttungen') + // UEBERSCHUSS = UEBERSCHUSS VOR ENTNAHMEN - Kapitalentnahmen + const entnahmen = findLiqMatch(['Kapitalentnahmen/Ausschüttungen', 'Kapitalentnahmen/Ausschuettungen']) if (ueberschuss && uebVorEnt && entnahmen) { 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)) @@ -128,27 +133,18 @@ export async function computeLiquiditaet( 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) { - 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 lq = emptyMonthly() for (let m = 1; m <= MONTHS; m++) { 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(lq), liquiditaet.id]) kontostand.values = ks liquiditaet.values = lq } - - return { endstand: liquiditaet?.values || emptyMonthly() } } diff --git a/pitch-deck/lib/finanzplan/engine-sheets.ts b/pitch-deck/lib/finanzplan/engine-sheets.ts index ea7ad01..539bb80 100644 --- a/pitch-deck/lib/finanzplan/engine-sheets.ts +++ b/pitch-deck/lib/finanzplan/engine-sheets.ts @@ -6,7 +6,7 @@ */ import { - MonthlyValues, MONTHS, FOUNDING_MONTH, + MonthlyValues, MONTHS, emptyMonthly, dateToMonth, monthToDate, FPPersonalkosten, FPInvestitionen, } from './types' diff --git a/pitch-deck/lib/finanzplan/engine.ts b/pitch-deck/lib/finanzplan/engine.ts index 7f530a2..b3f0466 100644 --- a/pitch-deck/lib/finanzplan/engine.ts +++ b/pitch-deck/lib/finanzplan/engine.ts @@ -4,40 +4,38 @@ * Dependency order: * Personalkosten (independent inputs) * Investitionen (independent inputs) - * Kunden → Umsatzerlöse → Materialaufwand + * Kunden -> Umsatzerloese -> Materialaufwand * Betriebliche Aufwendungen (needs Personal + Invest) - * Sonst. betr. Erträge (independent) - * Liquidität (aggregates all above) + * Sonst. betr. Ertraege (independent) + * Liquiditaet (aggregates all above) * GuV (annual summary) * - * Split into modules: - * engine-sheets.ts — pure calculators (no DB) - * engine-betrieb.ts — betriebliche aufwendungen - * engine-liquiditaet.ts — liquidity / cash flow - * engine-guv.ts — GuV / P&L + taxes + * Each computation step is delegated to a companion module: + * engine-sheets.ts — pure computation (Personal, Invest, aggregation) + * engine-betrieb.ts — formula-based opex + category sums + * engine-liquiditaet.ts — rolling cash balance + * engine-guv.ts — annual P&L + taxes */ import { Pool } from 'pg' import { MonthlyValues, MONTHS, FOUNDING_MONTH, - emptyMonthly, - FPPersonalkosten, FPInvestitionen, FPBetrieblicheAufwendungen, + emptyMonthly, FPBetrieblicheAufwendungen, FPLiquiditaet, FPComputeResult, } from './types' -import { - computePersonalkosten, computeInvestitionen, - sumField, computeHeadcount, -} from './engine-sheets' -import { computeBetrieblicheAufwendungen } from './engine-betrieb' -import { computeLiquiditaet } from './engine-liquiditaet' +import { computePersonalkosten, computeInvestitionen, sumField } from './engine-sheets' +import { computeBetrieblicheAufwendungen, computeCloudHosting } from './engine-betrieb' +import { computeLiquiditaet, computeRollingBalance } from './engine-liquiditaet' 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' -// Import types used inline +// Import types used only inside this file type FPUmsatzerloese = import('./types').FPUmsatzerloese type FPMaterialaufwand = import('./types').FPMaterialaufwand +type FPPersonalkosten = import('./types').FPPersonalkosten +type FPInvestitionen = import('./types').FPInvestitionen // --- Main Engine --- @@ -61,8 +59,12 @@ export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise const totalBrutto = sumField(personal as any, 'values_brutto') const totalSozial = sumField(personal as any, 'values_sozial') 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) { await pool.query( '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( - pool, umsatzRows.rows as FPUmsatzerloese[], materialRows.rows as FPMaterialaufwand[] + pool, umsatzRows.rows as FPUmsatzerloese[], materialRows.rows as FPMaterialaufwand[], ) // 5. Bestandskunden (for formula-based costs) - 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 - } - } - } + const totalBestandskunden = await loadBestandskunden(pool, scenarioId) - // Cloud-Hosting in Materialaufwand - const matRows = materialRows.rows as FPMaterialaufwand[] - 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 - } + // 5b. Cloud-Hosting formula in Materialaufwand + await computeCloudHosting(pool, materialRows.rows as FPMaterialaufwand[], totalBestandskunden) // 6. Betriebliche Aufwendungen const betrieb = betriebRows.rows as FPBetrieblicheAufwendungen[] - const { totalSonstige, totalGesamt } = await computeBetrieblicheAufwendungen(pool, betrieb, { + const { totalSonstige } = await computeBetrieblicheAufwendungen(pool, betrieb, { totalBrutto, totalPersonal, totalAfa, totalRevenue, totalMaterial, headcount, totalBestandskunden, }) - // 7. Liquidität + // 7. Liquiditaet (first pass) const liquid = liquidRows.rows as FPLiquiditaet[] - const { endstand } = await computeLiquiditaet(pool, liquid, { + await computeLiquiditaet(pool, liquid, { 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, { totalRevenue, totalMaterial, totalBrutto, totalSozial, 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 { personalkosten: { total_brutto: totalBrutto, total_sozial: totalSozial, total: totalPersonal, positions: personal, headcount }, investitionen: { total_invest: totalInvest, total_afa: totalAfa, items: invest }, umsatzerloese: { total: totalRevenue }, materialaufwand: { total: totalMaterial }, - betriebliche: { total_sonstige: totalSonstige, total_gesamt: totalGesamt }, - liquiditaet: { rows: liquid, endstand }, + betriebliche: { total_sonstige: totalSonstige, total_gesamt: gesamtBetrieb?.values || emptyMonthly() }, + liquiditaet: { rows: liquid, endstand: liquiditaetRow?.values || emptyMonthly() }, guv, } } -// --- Revenue & Material helpers (kept here to avoid circular deps) --- +// --- Revenue & Material helpers (kept in orchestrator — tightly coupled to DB writes) --- async function computeRevenueAndMaterial( pool: Pool, umsatzAllRows: FPUmsatzerloese[], materialAllRows: FPMaterialaufwand[], ): Promise<{ totalRevenue: MonthlyValues; totalMaterial: MonthlyValues }> { + // Umsatzerloese (quantity x price) const prices = umsatzAllRows.filter(r => r.section === 'price') const quantities = umsatzAllRows.filter(r => r.section === 'quantity') const revenueRows = umsatzAllRows.filter(r => r.section === 'revenue') const totalRevenue = emptyMonthly() const extractTier = (label: string) => { const m = label.match(/\(([^)]+)\)/); return m ? m[1] : label } - for (const rev of revenueRows) { if (rev.row_label === 'GESAMTUMSATZ') continue 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) if (qty && price) { 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(v) + rev.values[`m${m}`] = Math.round((qty.values[`m${m}`] || 0) * (price.values[`m${m}`] || 0)) } 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]) } - // Materialaufwand + // Materialaufwand (quantity x unit_cost) const matCosts = materialAllRows.filter(r => r.section === 'cost') const matUnitCosts = materialAllRows.filter(r => r.section === 'unit_cost') const totalMaterial = emptyMonthly() @@ -192,8 +179,7 @@ async function computeRevenueAndMaterial( const qty = quantities.find(q => q.row_label === cost.row_label) if (uc && qty) { 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(v) + cost.values[`m${m}`] = Math.round((qty.values[`m${m}`] || 0) * (uc.values[`m${m}`] || 0)) } 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 } } + +async function loadBestandskunden(pool: Pool, scenarioId: string): Promise { + 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 +} diff --git a/pitch-deck/middleware.ts b/pitch-deck/middleware.ts index fc6dfe4..6f5a7c7 100644 --- a/pitch-deck/middleware.ts +++ b/pitch-deck/middleware.ts @@ -6,7 +6,6 @@ const PUBLIC_PATHS = [ '/auth', // investor login pages '/api/auth', // investor auth API '/api/health', - '/api/admin/fp-patch', '/api/admin-auth', // admin login API '/pitch-admin/login', // admin login page '/_next', @@ -47,10 +46,14 @@ export async function middleware(request: NextRequest) { // ----- Admin-gated routes ----- if (isAdminGatedPath(pathname)) { - // Allow legacy bearer-secret CLI access on /api/admin/* (the API routes themselves - // also check this and log as actor='cli'). The bearer header is opaque to the JWT - // path, so we just let it through here and let the route handler enforce. + // Allow bearer-secret CLI access on /api/admin/* — validate the token here, + // not just in the route handler, to avoid any unprotected route slipping through. 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() } diff --git a/pitch-deck/next.config.js b/pitch-deck/next.config.js index 08d8000..b9e6e97 100644 --- a/pitch-deck/next.config.js +++ b/pitch-deck/next.config.js @@ -5,9 +5,6 @@ const nextConfig = { NEXT_PUBLIC_GIT_SHA: process.env.GIT_SHA || 'dev', }, reactStrictMode: true, - typescript: { - ignoreBuildErrors: true, - }, serverExternalPackages: ['nodemailer'], async headers() { return [ diff --git a/pitch-deck/tsconfig.json b/pitch-deck/tsconfig.json index ba48aa7..c446e16 100644 --- a/pitch-deck/tsconfig.json +++ b/pitch-deck/tsconfig.json @@ -14,7 +14,7 @@ "incremental": true, "plugins": [{ "name": "next" }], "paths": { "@/*": ["./*"] }, - "target": "ES2017" + "target": "ES2018" }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"]