From 71b6f8f181ffc8567546607d9a1781b50165c6b0 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:07:00 +0200 Subject: [PATCH 1/7] =?UTF-8?q?fix(pitch-deck):=20fix=20Liquidit=C3=A4t=20?= =?UTF-8?q?engine=20label=20mismatches=20+=20MilestonesSlide=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine now uses dynamic row_type-based summation instead of hardcoded label strings that differed between scenarios (e.g. 'Summe ERTRÄGE' vs 'Summe EINZAHLUNGEN'), fixing stale 9.2M value in Wandeldarlehen scenarios. Rolling balance now includes all financing cash flows via ÜBERSCHUSS chain. MilestonesSlide: widen Theme type to union so t.key comparisons compile. Co-Authored-By: Claude Sonnet 4.6 --- .../components/slides/MilestonesSlide.tsx | 2 +- pitch-deck/lib/finanzplan/engine.ts | 79 +++++++------------ 2 files changed, 28 insertions(+), 53 deletions(-) diff --git a/pitch-deck/components/slides/MilestonesSlide.tsx b/pitch-deck/components/slides/MilestonesSlide.tsx index c167d38..77332d1 100644 --- a/pitch-deck/components/slides/MilestonesSlide.tsx +++ b/pitch-deck/components/slides/MilestonesSlide.tsx @@ -147,7 +147,7 @@ const THEMES = { }, } -type Theme = typeof THEMES.dark +type Theme = typeof THEMES.dark | typeof THEMES.light // ── Data ────────────────────────────────────────────────────────────────────── const TODAY_POSITION = 0.56 diff --git a/pitch-deck/lib/finanzplan/engine.ts b/pitch-deck/lib/finanzplan/engine.ts index 721559b..e3299ae 100644 --- a/pitch-deck/lib/finanzplan/engine.ts +++ b/pitch-deck/lib/finanzplan/engine.ts @@ -413,57 +413,42 @@ export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise liqInvest.values = totalInvest } - // Compute sums and rolling balance - // WICHTIG: Überschuss = nur operativer Cashflow (ohne Kapitaleinzahlungen) - 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) + 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']) + // 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')) - // Dynamically categorize rows by row_type instead of hardcoded labels - // Operative Einzahlungen (OHNE Eigenkapital, Fremdkapital, Stammkapital, Wandeldarlehen) - const einzahlungenOperativ = ['Umsatzerlöse', 'Sonst. betriebl. Erträge', 'Anzahlungen'] - // Finanzierung: match any row with these keywords (handles renamed labels) - const finanzierungRows = liquid.filter(r => - r.row_type === 'einzahlung' && - !einzahlungenOperativ.includes(r.row_label) && - !r.row_label.includes('Summe') - ) - // Operative Auszahlungen - const auszahlungenOperativ = ['Materialaufwand', 'Personalkosten', 'Sonstige Kosten', 'Umsatzsteuer', 'Gewerbesteuer', 'Körperschaftsteuer'] - // Finanz-Auszahlungen: any auszahlung not in operativ list - const finanzAuszahlungRows = liquid.filter(r => - r.row_type === 'auszahlung' && - !auszahlungenOperativ.includes(r.row_label) && - !r.row_label.includes('Summe') - ) - - // Summe EINZAHLUNGEN = nur operativ (für die Zeile "Summe Einzahlungen") + // Summe ERTRÄGE = 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 = operative Einzahlungen - operative Auszahlungen + // ÜBERSCHUSS VOR INVESTITIONEN = Summe ERTRÄGE - 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)) @@ -471,7 +456,7 @@ export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise uebVorInv.values = s } - // ÜBERSCHUSS VOR ENTNAHMEN = Operativer Überschuss - Investitionen + // ÜBERSCHUSS VOR ENTNAHMEN = ÜBERSCHUSS 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)) @@ -479,8 +464,8 @@ export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise uebVorEnt.values = s } - // ÜBERSCHUSS = Überschuss vor Entnahmen - Entnahmen (immer noch rein operativ) - const entnahmen = findLiq('Kapitalentnahmen/Ausschüttungen') + // ÜBERSCHUSS = ÜBERSCHUSS 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)) @@ -488,24 +473,14 @@ export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise ueberschuss.values = s } - // Rolling Kontostand: Vormonat + Operativer Überschuss + Finanzierung - // Finanzierung = Eigenkapital + Fremdkapital - Kreditrückzahlungen + // Rolling balance: LIQUIDITÄT[m] = LIQUIDITÄT[m-1] + ÜBERSCHUSS[m] + // ÜBERSCHUSS now includes ALL cash flows (operative + financing + repayments) if (kontostand && liquiditaet && ueberschuss) { - // Berechne monatliche Finanzierungs-Cashflows - 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}`]) - // LIQUIDITÄT = Kontostand + Operativer Überschuss + Finanzierung - 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]) From 4e27e055122569161cb2667ee92333ccb769f7e7 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:24:13 +0200 Subject: [PATCH 2/7] fix(pitch-deck): chat agent now uses investor's assigned version scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadPitchContext() now accepts a versionId and loads data from pitch_version_data instead of hardcoded base table queries, matching the pattern used by /api/data and /api/financial-model. Also pulls fp_liquiditaet yearly summaries (LIQUIDITÄT, Summe ERTRÄGE, etc.) for the matching fp_scenario so the agent quotes the correct finanzplan numbers. Falls back to base tables when no version is assigned. Co-Authored-By: Claude Sonnet 4.6 --- pitch-deck/app/api/chat/route.ts | 151 +++++++++++++++++++++++++------ 1 file changed, 124 insertions(+), 27 deletions(-) diff --git a/pitch-deck/app/api/chat/route.ts b/pitch-deck/app/api/chat/route.ts index 5709156..7a52479 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' @@ -115,8 +116,79 @@ 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) 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 '' + } +} + +async function loadPitchContext(versionId?: string | null): Promise { + try { + // Version-specific data path + if (versionId) { + const { rows: vRows } = await pool.query( + `SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`, + [versionId] + ) + const map: Record = {} + for (const r of vRows) { + 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 unknown[]) ?? [] + const market = (map.market as unknown[]) ?? [] + const products = (map.products as unknown[]) ?? [] + const funding = (map.funding as unknown[])?.[0] ?? null + const features = ((map.features as Array>) ?? []) + .filter(f => f.is_differentiator) + + const fmScenarios = map.fm_scenarios as Array<{ name: string }> | undefined + const scenarioName = fmScenarios?.[0]?.name ?? '' + const fpSummary = scenarioName ? await loadFpLiquiditaetSummary(scenarioName) : '' + + return buildContextString(company, team, financials, market, products, funding, features, fpSummary) + } + + // Fallback: base tables const client = await pool.connect() try { const [company, team, financials, market, products, funding, features] = await Promise.all([ @@ -128,31 +200,10 @@ 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)} -` + return buildContextString( + company.rows[0], team.rows, financials.rows, market.rows, + products.rows, funding.rows[0], features.rows, '' + ) } finally { client.release() } @@ -162,6 +213,37 @@ ${JSON.stringify(features.rows, null, 2)} } } +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() @@ -171,7 +253,22 @@ export async function POST(request: NextRequest) { 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 + } + + const pitchContext = await loadPitchContext(versionId) let systemContent = SYSTEM_PROMPT if (pitchContext) { From a795794f94b9ee1a4e59ef1b9f7392d1a0127066 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:40:07 +0200 Subject: [PATCH 3/7] fix(pitch-deck): FAQ version-data priority override in chat system prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FAQ entries contain hardcoded financial numbers written for specific scenarios (e.g. 470k Liquidität 2027, 200k/40k WD amounts). When an investor is on a different version, those FAQ numbers would override the correct version-specific context already injected from pitch_version_data. Added an explicit priority instruction: version-specific Unternehmensdaten always override FAQ content for any conflicting numbers. Co-Authored-By: Claude Sonnet 4.6 --- pitch-deck/app/api/chat/route.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pitch-deck/app/api/chat/route.ts b/pitch-deck/app/api/chat/route.ts index 7a52479..6db7b4f 100644 --- a/pitch-deck/app/api/chat/route.ts +++ b/pitch-deck/app/api/chat/route.ts @@ -276,8 +276,11 @@ export async function POST(request: NextRequest) { } // 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 From b1ef6a85d687fbd369567c4df049f082419bfbaa Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:44:41 +0200 Subject: [PATCH 4/7] fix(pitch-deck): dynamic VERSIONS-ISOLATION and Kernbotschaft from version data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes all hardcoded version-specific numbers from SYSTEM_PROMPT (200k, 40k/160k L-Bank split, 195 Kunden, 3.3 Mio, 9 MA). These are now generated at runtime from the investor's assigned pitch_version_data: funding amount, instrument, fm_scenarios name, and 2030 financials (customers, revenue, employees). loadPitchContext() now returns { contextString, meta } so the POST handler can build correct isolation and Kernbotschaft strings for any version — Wandeldarlehen 200k, 1 Mio, or any future scenario. Co-Authored-By: Claude Sonnet 4.6 --- pitch-deck/app/api/chat/route.ts | 121 ++++++++++++++++++++++++------- 1 file changed, 94 insertions(+), 27 deletions(-) diff --git a/pitch-deck/app/api/chat/route.ts b/pitch-deck/app/api/chat/route.ts index 6db7b4f..7168336 100644 --- a/pitch-deck/app/api/chat/route.ts +++ b/pitch-deck/app/api/chat/route.ts @@ -57,7 +57,7 @@ Du hast Zugriff auf alle Unternehmensdaten und zitierst immer konkrete Zahlen. 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." +9. Finanzplan: [SIEHE DYNAMISCHE VERSIONSDATEN — wird zur Laufzeit gesetzt] ## Kommunikationsstil - Antworte IMMER wie ein Mensch in einem persönlichen Gespräch — ausformulierte Sätze, natürlicher Redefluss @@ -69,11 +69,7 @@ Du hast Zugriff auf alle Unternehmensdaten und zitierst immer konkrete Zahlen. - 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. +[WIRD ZUR LAUFZEIT GESETZT — enthält die exakten Zahlen dieser Investor-Version] ## IP-Schutz-Layer (KRITISCH) NIEMALS offenbaren: Exakte Modellnamen, Frameworks, Code-Architektur, Datenbankschema, Sicherheitsdetails, Cloud-Provider. @@ -107,7 +103,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? @@ -159,33 +155,76 @@ async function loadFpLiquiditaetSummary(scenarioName: string): Promise { } } -async function loadPitchContext(versionId?: string | null): Promise { +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 { rows: vRows } = await pool.query( - `SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`, - [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 vRows) { + 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 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] ?? null + 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 scenarioName = fmScenarios?.[0]?.name ?? '' - const fpSummary = scenarioName ? await loadFpLiquiditaetSummary(scenarioName) : '' - return buildContextString(company, team, financials, market, products, funding, features, fpSummary) + 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 @@ -200,16 +239,20 @@ async function loadPitchContext(versionId?: string | null): 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 buildContextString( - company.rows[0], team.rows, financials.rows, market.rows, - products.rows, funding.rows[0], features.rows, '' - ) + 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 } } } @@ -268,11 +311,35 @@ export async function POST(request: NextRequest) { // Non-fatal: fall back to base tables } - const pitchContext = await loadPitchContext(versionId) + 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 label = meta.scenarioName || meta.versionName || 'diese Version' + + const dynamicVersionIsolation = `## VERSIONS-ISOLATION (ABSOLUT KRITISCH) +- Du kennst NUR die Version "${label}" mit ${fundingStr}. +- Es gibt KEINE andere Version. Dieses Pitch Deck wurde individuell für diesen Investor erstellt. +- 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: ${label}, ${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 - if (pitchContext) { - systemContent += '\n' + pitchContext + .replace('9. Finanzplan: [SIEHE DYNAMISCHE VERSIONSDATEN — wird zur Laufzeit gesetzt]', dynamicFinanzplanKernbotschaft) + .replace('## VERSIONS-ISOLATION (ABSOLUT KRITISCH)\n[WIRD ZUR LAUFZEIT GESETZT — enthält die exakten Zahlen dieser Investor-Version]', dynamicVersionIsolation) + + if (contextString) { + systemContent += '\n' + contextString } // FAQ context: relevant pre-researched answers as basis for the LLM From 59e55f8740890a0d1f8ce2c775aa6930e013e797 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:27:58 +0200 Subject: [PATCH 5/7] fix(pitch-deck): remove version name from isolation prompt to avoid leaking multiplicity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Using terms like 'Version X' or 'Szenario Y' in the VERSIONS-ISOLATION instruction implies other versions exist. Rewritten to never reference version/scenario names — just 'this pitch deck, created for you, the only one'. Co-Authored-By: Claude Sonnet 4.6 --- pitch-deck/app/api/chat/route.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/pitch-deck/app/api/chat/route.ts b/pitch-deck/app/api/chat/route.ts index 7168336..43f11a1 100644 --- a/pitch-deck/app/api/chat/route.ts +++ b/pitch-deck/app/api/chat/route.ts @@ -323,14 +323,13 @@ export async function POST(request: NextRequest) { : meta.fundingInstrument const customersStr = meta.customers2030 > 0 ? `~${meta.customers2030} Kunden` : 'laut Finanzplan' const employeesStr = meta.employees2030 > 0 ? `${meta.employees2030} Mitarbeiter` : 'laut Finanzplan' - const label = meta.scenarioName || meta.versionName || 'diese Version' const dynamicVersionIsolation = `## VERSIONS-ISOLATION (ABSOLUT KRITISCH) -- Du kennst NUR die Version "${label}" mit ${fundingStr}. -- Es gibt KEINE andere Version. Dieses Pitch Deck wurde individuell für diesen Investor erstellt. -- 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: ${label}, ${customersStr} bis 2030, ${revM} Umsatz, ${employeesStr}.` +- 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}."` From 75bd0c29f38314be6672c358f08e5a52d9d6faec Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:53:52 +0200 Subject: [PATCH 6/7] fix(pitch-deck): eliminate SYSTEM_PROMPT placeholder leak and fix liquidity tax ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C3: Split SYSTEM_PROMPT into PART1/PART2/PART3 constants; Kernbotschaft #9 and VERSIONS-ISOLATION now concatenated directly at runtime instead of .replace() — a whitespace mismatch can no longer cause placeholder text to leak verbatim to the LLM. I2: Add second liquidity-chain pass (sumAus→ÜBERSCHUSS→rolling balance) after tax rows (Gewerbesteuer/Körperschaftsteuer) are written to fp_liquiditaet, so first-run LIQUIDITÄT figures include tax outflows without requiring a second engine invocation. I6: Warn when loadFpLiquiditaetSummary finds no fp_liquiditaet rows for a named scenario, surfacing scenario-name mismatches that would otherwise silently return empty context. I8: Sanitize console.error calls in chat/route.ts (3 sites) and data/route.ts; cap LiteLLM error body to 200 chars, use (error as Error).message for stream/handler errors. Co-Authored-By: Claude Sonnet 4.6 --- pitch-deck/app/api/chat/route.ts | 34 +++++++++++++--------- pitch-deck/app/api/data/route.ts | 2 +- pitch-deck/lib/finanzplan/engine.ts | 44 +++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/pitch-deck/app/api/chat/route.ts b/pitch-deck/app/api/chat/route.ts index 43f11a1..43de3ef 100644 --- a/pitch-deck/app/api/chat/route.ts +++ b/pitch-deck/app/api/chat/route.ts @@ -35,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 @@ -56,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: [SIEHE DYNAMISCHE VERSIONSDATEN — wird zur Laufzeit gesetzt] +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 @@ -66,10 +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) -[WIRD ZUR LAUFZEIT GESETZT — enthält die exakten Zahlen dieser Investor-Version] +// 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. @@ -123,7 +126,10 @@ async function loadFpLiquiditaetSummary(scenarioName: string): Promise { ORDER BY l.sort_order`, [scenarioName] ) - if (rows.length === 0) return '' + 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> = {} @@ -333,9 +339,11 @@ export async function POST(request: NextRequest) { const dynamicFinanzplanKernbotschaft = `9. Finanzplan: "Gründung August 2026. Pre-Seed über ${fundingStr}. ${customersStr} und ${revM} Umsatz bis 2030. ${employeesStr}."` - let systemContent = SYSTEM_PROMPT - .replace('9. Finanzplan: [SIEHE DYNAMISCHE VERSIONSDATEN — wird zur Laufzeit gesetzt]', dynamicFinanzplanKernbotschaft) - .replace('## VERSIONS-ISOLATION (ABSOLUT KRITISCH)\n[WIRD ZUR LAUFZEIT GESETZT — enthält die exakten Zahlen dieser Investor-Version]', dynamicVersionIsolation) + let systemContent = SYSTEM_PROMPT_PART1 + + '\n' + dynamicFinanzplanKernbotschaft + + SYSTEM_PROMPT_PART2 + + '\n\n' + dynamicVersionIsolation + + SYSTEM_PROMPT_PART3 if (contextString) { systemContent += '\n' + contextString @@ -402,7 +410,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 } @@ -461,7 +469,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() } @@ -476,7 +484,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/lib/finanzplan/engine.ts b/pitch-deck/lib/finanzplan/engine.ts index e3299ae..b66188b 100644 --- a/pitch-deck/lib/finanzplan/engine.ts +++ b/pitch-deck/lib/finanzplan/engine.ts @@ -623,6 +623,50 @@ export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise liqKSt.values = v } + // Second pass: tax rows (liqGewSt/liqKSt) were written after the rolling balance above. + // Recompute Summe AUSZAHLUNGEN → ÜBERSCHUSS chain → rolling balance so taxes are included + // even on the first engine run (when tax rows were previously zero). + if (sumAus) { + const s = emptyMonthly() + 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 + } + 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)) + await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), uebVorInv.id]) + uebVorInv.values = s + } + 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)) + await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), uebVorEnt.id]) + uebVorEnt.values = s + } + 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)) + await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), ueberschuss.id]) + ueberschuss.values = s + } + if (kontostand && liquiditaet && ueberschuss) { + 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)) + } + 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 { personalkosten: { total_brutto: totalBrutto, total_sozial: totalSozial, total: totalPersonal, positions: personal, headcount }, investitionen: { total_invest: totalInvest, total_afa: totalAfa, items: invest }, From 41bc522b5b79801ef571234aa8164093bd230681 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:08:50 +0200 Subject: [PATCH 7/7] fix(pitch-deck): close auth gaps, isolate finanzplan scenario access, enforce TS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D1: Remove /api/admin/fp-patch from PUBLIC_PATHS — it was returning live financial data (fp_liquiditaet rows) to any unauthenticated caller; middleware admin gate now applies as it does for all /api/admin/* paths. D2: Add PITCH_ADMIN_SECRET bearer guard to POST /api/financial-model (create scenario) and PUT /api/financial-model/assumptions (update assumptions) — any authenticated investor could previously create/modify global financial model data. D3: Add PITCH_ADMIN_SECRET bearer guard to POST /api/finanzplan/compute — any investor could trigger a full DB recomputation across all fp_* tables. Also replace String(error) in error response with a static message. D4: GET /api/finanzplan/[sheetName] now ignores ?scenarioId= for non-admin callers; investors always receive the default scenario only. Previously any investor could enumerate UUIDs and read any scenario's financials including other investors' plans. D9: Remove `name` from the non-admin /api/finanzplan response — scenario names like "Wandeldarlehen v2" reveal internal versioning to investors. D10: Remove hardcoded postgres://breakpilot:breakpilot123@localhost fallback from lib/db.ts — missing DATABASE_URL now fails loudly instead of silently using stale credentials that are committed to the repository. D6: Fix all 4 TypeScript errors that were masked by ignoreBuildErrors:true; bump tsconfig target to ES2018 (regex s flag in ChatFAB), type lang as 'de'|'en' in chat route, add 'as string' assertion in adapter.ts. Remove ignoreBuildErrors:true from next.config.js so future type errors fail the build rather than being silently shipped. Co-Authored-By: Claude Sonnet 4.6 --- pitch-deck/app/api/chat/route.ts | 3 +- .../api/financial-model/assumptions/route.ts | 6 +- pitch-deck/app/api/financial-model/route.ts | 7 +- .../app/api/finanzplan/[sheetName]/route.ts | 86 ++++++++++++++----- .../app/api/finanzplan/compute/route.ts | 6 +- pitch-deck/app/api/finanzplan/route.ts | 14 ++- pitch-deck/lib/db.ts | 2 +- pitch-deck/lib/finanzplan/adapter.ts | 6 +- pitch-deck/middleware.ts | 11 ++- pitch-deck/next.config.js | 3 - pitch-deck/tsconfig.json | 2 +- 11 files changed, 105 insertions(+), 41 deletions(-) diff --git a/pitch-deck/app/api/chat/route.ts b/pitch-deck/app/api/chat/route.ts index 43de3ef..f274842 100644 --- a/pitch-deck/app/api/chat/route.ts +++ b/pitch-deck/app/api/chat/route.ts @@ -296,7 +296,8 @@ ${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 }) 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/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/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"]