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