feat: add pitch-deck service to core infrastructure
Migrated pitch-deck from breakpilot-pwa to breakpilot-core. Container: bp-core-pitch-deck on port 3012. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
181
pitch-deck/app/api/financial-model/compute/route.ts
Normal file
181
pitch-deck/app/api/financial-model/compute/route.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
|
||||
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[]> = {}
|
||||
for (const row of assumptionsRes.rows) {
|
||||
const val = typeof row.value === 'string' ? JSON.parse(row.value) : row.value
|
||||
a[row.key] = val
|
||||
}
|
||||
|
||||
// Extract scalar values
|
||||
const initialFunding = Number(a.initial_funding) || 200000
|
||||
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
|
||||
const hiringPlan: number[] = Array.isArray(a.hiring_plan) ? a.hiring_plan : [2, 4, 8, 12, 18]
|
||||
const marketingMonthly = Number(a.marketing_monthly) || 2000
|
||||
const infraBase = Number(a.infra_monthly_base) || 500
|
||||
|
||||
// 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
|
||||
|
||||
const results = []
|
||||
let totalCustomers = initialCustomers
|
||||
let cashBalance = initialFunding
|
||||
let cumulativeRevenue = 0
|
||||
let breakEvenMonth: number | null = null
|
||||
let peakBurn = 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
|
||||
|
||||
// Employees from hiring plan
|
||||
const employees = hiringPlan[Math.min(yearIndex, hiringPlan.length - 1)] || 2
|
||||
|
||||
// Customer dynamics
|
||||
const newCustomers = m === 1 ? initialCustomers : Math.max(1, Math.round(totalCustomers * growthRate))
|
||||
const churned = Math.round(totalCustomers * churnRate)
|
||||
if (m > 1) {
|
||||
totalCustomers = totalCustomers + newCustomers - churned
|
||||
}
|
||||
totalCustomers = Math.max(0, totalCustomers)
|
||||
|
||||
// Revenue
|
||||
const mrr = totalCustomers * weightedArpu
|
||||
const arr = mrr * 12
|
||||
const revenue = mrr
|
||||
|
||||
// Costs
|
||||
const personnelCost = employees * salaryAvg
|
||||
const cogsHardware = newCustomers * hwCostWeighted
|
||||
const cogsCloud = totalCustomers * mixCloud * cloudOpex
|
||||
const cogs = cogsHardware + cogsCloud
|
||||
const marketingCost = marketingMonthly + (newCustomers * cac)
|
||||
const infraCost = infraBase + (totalCustomers * 5) // infra scales slightly
|
||||
const totalCosts = personnelCost + cogs + marketingCost + infraCost
|
||||
|
||||
// Cash
|
||||
const netCash = revenue - totalCosts
|
||||
cashBalance += netCash
|
||||
cumulativeRevenue += revenue
|
||||
|
||||
// KPIs
|
||||
const grossMargin = revenue > 0 ? ((revenue - cogs) / revenue) * 100 : 0
|
||||
const burnRate = netCash < 0 ? Math.abs(netCash) : 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 && netCash >= 0 && m > 1) {
|
||||
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: employees,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// Save to DB (upsert)
|
||||
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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user