All checks were successful
Build pitch-deck / build-push-deploy (push) Successful in 1m38s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 38s
CI / test-python-voice (push) Successful in 36s
CI / test-bqas (push) Successful in 33s
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 <noreply@anthropic.com>
640 lines
28 KiB
TypeScript
640 lines
28 KiB
TypeScript
/**
|
||
* Finanzplan Compute Engine
|
||
*
|
||
* Dependency order:
|
||
* Personalkosten (independent inputs)
|
||
* Investitionen (independent inputs)
|
||
* Kunden → Umsatzerlöse → Materialaufwand
|
||
* Betriebliche Aufwendungen (needs Personal + Invest)
|
||
* Sonst. betr. Erträge (independent)
|
||
* Liquidität (aggregates all above)
|
||
* GuV (annual summary)
|
||
*/
|
||
|
||
import { Pool } from 'pg'
|
||
import {
|
||
MonthlyValues, AnnualValues, MONTHS, FOUNDING_MONTH,
|
||
emptyMonthly, sumMonthly, annualSums, dateToMonth, monthToDate,
|
||
FPPersonalkosten, FPInvestitionen, FPBetrieblicheAufwendungen,
|
||
FPLiquiditaet, FPComputeResult
|
||
} from './types'
|
||
|
||
// --- Sheet Calculators ---
|
||
|
||
export function computePersonalkosten(positions: FPPersonalkosten[]): FPPersonalkosten[] {
|
||
return positions.map(p => {
|
||
const brutto = emptyMonthly()
|
||
const sozial = emptyMonthly()
|
||
const total = emptyMonthly()
|
||
|
||
if (!p.start_date || !p.brutto_monthly) return { ...p, values_brutto: brutto, values_sozial: sozial, values_total: total }
|
||
|
||
const startDate = new Date(p.start_date)
|
||
const startM = dateToMonth(startDate.getFullYear(), startDate.getMonth() + 1)
|
||
const endM = p.end_date
|
||
? dateToMonth(new Date(p.end_date).getFullYear(), new Date(p.end_date).getMonth() + 1)
|
||
: MONTHS
|
||
|
||
for (let m = Math.max(1, startM); m <= Math.min(MONTHS, endM); m++) {
|
||
const { year } = monthToDate(m)
|
||
const yearsFromStart = year - startDate.getFullYear()
|
||
const raise = Math.pow(1 + (p.annual_raise_pct || 0) / 100, yearsFromStart)
|
||
const monthlyBrutto = Math.round(p.brutto_monthly * raise)
|
||
|
||
brutto[`m${m}`] = monthlyBrutto
|
||
sozial[`m${m}`] = Math.round(monthlyBrutto * (p.ag_sozial_pct || 20.425) / 100)
|
||
total[`m${m}`] = brutto[`m${m}`] + sozial[`m${m}`]
|
||
}
|
||
|
||
return { ...p, values_brutto: brutto, values_sozial: sozial, values_total: total }
|
||
})
|
||
}
|
||
|
||
export function computeInvestitionen(items: FPInvestitionen[]): FPInvestitionen[] {
|
||
return items.map(item => {
|
||
const invest = emptyMonthly()
|
||
const afa = emptyMonthly()
|
||
|
||
if (!item.purchase_date || !item.purchase_amount) return { ...item, values_invest: invest, values_afa: afa }
|
||
|
||
const d = new Date(item.purchase_date)
|
||
const purchaseM = dateToMonth(d.getFullYear(), d.getMonth() + 1)
|
||
|
||
if (purchaseM >= 1 && purchaseM <= MONTHS) {
|
||
invest[`m${purchaseM}`] = item.purchase_amount
|
||
}
|
||
|
||
// AfA (linear depreciation)
|
||
if (item.afa_years && item.afa_years > 0) {
|
||
const afaMonths = item.afa_years * 12
|
||
const monthlyAfa = Math.round(item.purchase_amount / afaMonths)
|
||
for (let m = purchaseM; m < purchaseM + afaMonths && m <= MONTHS; m++) {
|
||
if (m >= 1) afa[`m${m}`] = monthlyAfa
|
||
}
|
||
} else {
|
||
// GWG: full depreciation in purchase month
|
||
if (purchaseM >= 1 && purchaseM <= MONTHS) {
|
||
afa[`m${purchaseM}`] = item.purchase_amount
|
||
}
|
||
}
|
||
|
||
return { ...item, values_invest: invest, values_afa: afa }
|
||
})
|
||
}
|
||
|
||
function sumRows(rows: { values: MonthlyValues }[]): MonthlyValues {
|
||
const result = emptyMonthly()
|
||
for (const row of rows) {
|
||
for (let m = 1; m <= MONTHS; m++) {
|
||
result[`m${m}`] += row.values[`m${m}`] || 0
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
function sumField(rows: { [key: string]: MonthlyValues }[], field: string): MonthlyValues {
|
||
const result = emptyMonthly()
|
||
for (const row of rows) {
|
||
const v = row[field] as MonthlyValues
|
||
if (!v) continue
|
||
for (let m = 1; m <= MONTHS; m++) {
|
||
result[`m${m}`] += v[`m${m}`] || 0
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// --- Main Engine ---
|
||
|
||
export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise<FPComputeResult> {
|
||
// 1. Load all editable data from DB
|
||
const [
|
||
personalRows,
|
||
investRows,
|
||
betriebRows,
|
||
liquidRows,
|
||
kundenSummary,
|
||
umsatzRows,
|
||
materialRows,
|
||
] = await Promise.all([
|
||
pool.query('SELECT * FROM fp_personalkosten WHERE scenario_id = $1 ORDER BY sort_order', [scenarioId]),
|
||
pool.query('SELECT * FROM fp_investitionen WHERE scenario_id = $1 ORDER BY sort_order', [scenarioId]),
|
||
pool.query('SELECT * FROM fp_betriebliche_aufwendungen WHERE scenario_id = $1 ORDER BY sort_order', [scenarioId]),
|
||
pool.query('SELECT * FROM fp_liquiditaet WHERE scenario_id = $1 ORDER BY sort_order', [scenarioId]),
|
||
pool.query('SELECT * FROM fp_kunden_summary WHERE scenario_id = $1 ORDER BY sort_order', [scenarioId]),
|
||
pool.query('SELECT * FROM fp_umsatzerloese WHERE scenario_id = $1 ORDER BY sort_order', [scenarioId]),
|
||
pool.query('SELECT * FROM fp_materialaufwand WHERE scenario_id = $1 ORDER BY sort_order', [scenarioId]),
|
||
])
|
||
|
||
// 2. Compute Personalkosten
|
||
const personal = computePersonalkosten(personalRows.rows as FPPersonalkosten[])
|
||
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 = 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',
|
||
[JSON.stringify(p.values_brutto), JSON.stringify(p.values_sozial), JSON.stringify(p.values_total), p.id]
|
||
)
|
||
}
|
||
|
||
// 3. Compute Investitionen
|
||
const invest = computeInvestitionen(investRows.rows as FPInvestitionen[])
|
||
const totalInvest = sumField(invest as any, 'values_invest')
|
||
const totalAfa = sumField(invest as any, 'values_afa')
|
||
|
||
for (const i of invest) {
|
||
await pool.query(
|
||
'UPDATE fp_investitionen SET values_invest = $1, values_afa = $2 WHERE id = $3',
|
||
[JSON.stringify(i.values_invest), JSON.stringify(i.values_afa), i.id]
|
||
)
|
||
}
|
||
|
||
// 4. Umsatzerlöse (quantity × price)
|
||
const prices = (umsatzRows.rows as FPUmsatzerloese[]).filter(r => r.section === 'price')
|
||
const quantities = (umsatzRows.rows as FPUmsatzerloese[]).filter(r => r.section === 'quantity')
|
||
const revenueRows = (umsatzRows.rows as FPUmsatzerloese[]).filter(r => r.section === 'revenue')
|
||
const totalRevenue = emptyMonthly()
|
||
|
||
// Revenue = quantity × price for each module (if qty+price exist)
|
||
// Match by tier name extracted from parentheses, or exact label match
|
||
const extractTier = (label: string) => { const m = label.match(/\(([^)]+)\)/); return m ? m[1] : label }
|
||
// Revenue rows WITHOUT matching qty/price are kept as-is (e.g. Beratung & Service)
|
||
for (const rev of revenueRows) {
|
||
if (rev.row_label === 'GESAMTUMSATZ') continue
|
||
const tier = extractTier(rev.row_label)
|
||
const qty = quantities.find(q => extractTier(q.row_label) === tier) || quantities.find(q => q.row_label === rev.row_label)
|
||
const price = prices.find(p => extractTier(p.row_label) === tier) || prices.find(p => p.row_label === rev.row_label)
|
||
if (qty && price) {
|
||
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)
|
||
}
|
||
await pool.query('UPDATE fp_umsatzerloese SET values = $1 WHERE id = $2', [JSON.stringify(rev.values), rev.id])
|
||
}
|
||
// Add ALL revenue rows to total (computed or manual)
|
||
for (let m = 1; m <= MONTHS; m++) {
|
||
totalRevenue[`m${m}`] += rev.values[`m${m}`] || 0
|
||
}
|
||
}
|
||
// Update GESAMTUMSATZ
|
||
const gesamtUmsatz = revenueRows.find(r => r.row_label === 'GESAMTUMSATZ')
|
||
if (gesamtUmsatz) {
|
||
await pool.query('UPDATE fp_umsatzerloese SET values = $1 WHERE id = $2', [JSON.stringify(totalRevenue), gesamtUmsatz.id])
|
||
}
|
||
|
||
// 5. Materialaufwand (quantity × unit_cost) — simplified
|
||
const matCosts = (materialRows.rows as FPMaterialaufwand[]).filter(r => r.section === 'cost')
|
||
const matUnitCosts = (materialRows.rows as FPMaterialaufwand[]).filter(r => r.section === 'unit_cost')
|
||
const totalMaterial = emptyMonthly()
|
||
|
||
for (const cost of matCosts) {
|
||
if (cost.row_label === 'SUMME') continue
|
||
const uc = matUnitCosts.find(u => u.row_label === cost.row_label)
|
||
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)
|
||
}
|
||
await pool.query('UPDATE fp_materialaufwand SET values = $1 WHERE id = $2', [JSON.stringify(cost.values), cost.id])
|
||
}
|
||
// Add ALL cost rows to total (computed or manual)
|
||
for (let m = 1; m <= MONTHS; m++) {
|
||
totalMaterial[`m${m}`] += cost.values[`m${m}`] || 0
|
||
}
|
||
}
|
||
const matSumme = matCosts.find(r => r.row_label === 'SUMME')
|
||
if (matSumme) {
|
||
await pool.query('UPDATE fp_materialaufwand SET values = $1 WHERE id = $2', [JSON.stringify(totalMaterial), matSumme.id])
|
||
}
|
||
|
||
// 5b. Headcount without founders (for formula-based costs)
|
||
const NUM_FOUNDERS = 2
|
||
const hcWithoutFounders = emptyMonthly()
|
||
for (let m = 1; m <= MONTHS; m++) {
|
||
hcWithoutFounders[`m${m}`] = Math.max(0, headcount[`m${m}`] - NUM_FOUNDERS)
|
||
}
|
||
|
||
// 5c. Total Bestandskunden (for Bewirtungskosten — uses totalBestandskunden from Serverkosten above)
|
||
// Also load enterprise customers separately for legacy compatibility
|
||
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 enterpriseKunden = emptyMonthly()
|
||
for (const row of kundenRows.rows) {
|
||
if (row.segment_name?.toLowerCase().includes('enterprise')) {
|
||
for (let m = 1; m <= MONTHS; m++) {
|
||
enterpriseKunden[`m${m}`] = row.values?.[`m${m}`] || 0
|
||
}
|
||
}
|
||
}
|
||
|
||
// 6. Betriebliche Aufwendungen — compute formula-based rows + sum rows
|
||
const betrieb = betriebRows.rows as FPBetrieblicheAufwendungen[]
|
||
|
||
// Pre-compute total Bestandskunden (needed for Bewirtungskosten + Serverkosten)
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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: headcount },
|
||
{ label: 'Bewirtungskosten (F)', perUnit: 50, source: totalBestandskunden },
|
||
{ label: 'Internet/Mobilfunk (F)', perUnit: 50, source: headcount },
|
||
]
|
||
|
||
for (const fr of formulaRows) {
|
||
const row = betrieb.find(r => r.row_label === fr.label)
|
||
if (row) {
|
||
const computed = emptyMonthly()
|
||
for (let m = FOUNDING_MONTH; m <= MONTHS; m++) {
|
||
computed[`m${m}`] = Math.round((fr.source[`m${m}`] || 0) * fr.perUnit)
|
||
}
|
||
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(computed), row.id])
|
||
row.values = computed
|
||
}
|
||
}
|
||
|
||
// Berufsgenossenschaft (VBG IT/Büro): ~0.5% of total brutto payroll
|
||
const bgRow = betrieb.find(r => r.row_label.includes('Berufsgenossenschaft'))
|
||
if (bgRow) {
|
||
const computed = emptyMonthly()
|
||
for (let m = FOUNDING_MONTH; m <= MONTHS; m++) {
|
||
computed[`m${m}`] = Math.round((totalBrutto[`m${m}`] || 0) * 0.005)
|
||
}
|
||
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(computed), bgRow.id])
|
||
bgRow.values = computed
|
||
}
|
||
|
||
// Allgemeine Marketingkosten: 8% of revenue (2026-2028), 10% from 2029
|
||
const marketingRow = betrieb.find(r => r.row_label.includes('Allgemeine Marketingkosten'))
|
||
if (marketingRow) {
|
||
const computed = emptyMonthly()
|
||
for (let m = FOUNDING_MONTH; m <= MONTHS; m++) {
|
||
const rate = m <= 36 ? 0.08 : 0.10 // m36 = Dec 2028
|
||
computed[`m${m}`] = Math.round((totalRevenue[`m${m}`] || 0) * rate)
|
||
}
|
||
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(computed), marketingRow.id])
|
||
marketingRow.values = computed
|
||
}
|
||
|
||
// Serverkosten now in Materialaufwand — compute Cloud-Hosting formula there
|
||
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) // 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
|
||
}
|
||
|
||
// Update Personalkosten row
|
||
const persBetrieb = betrieb.find(r => r.row_label === 'Personalkosten')
|
||
if (persBetrieb) {
|
||
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(totalPersonal), persBetrieb.id])
|
||
persBetrieb.values = totalPersonal
|
||
}
|
||
// Update Abschreibungen row
|
||
const abrBetrieb = betrieb.find(r => r.row_label === 'Abschreibungen')
|
||
if (abrBetrieb) {
|
||
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(totalAfa), abrBetrieb.id])
|
||
abrBetrieb.values = totalAfa
|
||
}
|
||
|
||
// 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 =>
|
||
r.category !== 'steuern' && r.category !== 'personal' && r.category !== 'abschreibungen' &&
|
||
!r.is_sum_row && !r.row_label.includes('Summe') && !r.row_label.includes('SUMME')
|
||
)
|
||
const computed = emptyMonthly()
|
||
for (let m = FOUNDING_MONTH; m <= MONTHS; m++) {
|
||
const rev = totalRevenue[`m${m}`] || 0
|
||
const mat = totalMaterial[`m${m}`] || 0
|
||
const pers = totalPersonal[`m${m}`] || 0
|
||
const afa = totalAfa[`m${m}`] || 0
|
||
let opex = 0
|
||
for (const r of nonTaxOpex) { opex += r.values[`m${m}`] || 0 }
|
||
const profit = rev - mat - pers - afa - opex
|
||
computed[`m${m}`] = profit > 0 ? Math.round(profit * 0.1225) : 0
|
||
}
|
||
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(computed), gewStRow.id])
|
||
gewStRow.values = computed
|
||
}
|
||
|
||
// Compute category sums
|
||
const categories = ['steuern', 'versicherungen', 'besondere', 'marketing', 'sonstige']
|
||
for (const cat of categories) {
|
||
const sumRow = betrieb.find(r => r.category === cat && r.is_sum_row)
|
||
const detailRows = betrieb.filter(r => r.category === cat && !r.is_sum_row)
|
||
if (sumRow && detailRows.length > 0) {
|
||
const s = sumRows(detailRows)
|
||
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(s), sumRow.id])
|
||
sumRow.values = s
|
||
}
|
||
}
|
||
|
||
// Summe sonstige (ohne Personal, Abschreibungen)
|
||
const sonstSumme = betrieb.find(r => r.row_label.includes('Summe sonstige'))
|
||
if (sonstSumme) {
|
||
const nonPersonNonAbr = betrieb.filter(r =>
|
||
r.row_label !== 'Personalkosten' && r.row_label !== 'Abschreibungen' &&
|
||
!r.row_label.includes('Summe sonstige') && !r.row_label.includes('Gesamtkosten') &&
|
||
!r.is_sum_row && r.category !== 'personal' && r.category !== 'abschreibungen'
|
||
)
|
||
const s = sumRows(nonPersonNonAbr)
|
||
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(s), sonstSumme.id])
|
||
sonstSumme.values = s
|
||
}
|
||
|
||
// Gesamtkosten
|
||
const gesamtBetrieb = betrieb.find(r => r.row_label.includes('Gesamtkosten') || r.row_label.includes('SUMME Betriebliche'))
|
||
const totalSonstige = sonstSumme?.values || emptyMonthly()
|
||
if (gesamtBetrieb) {
|
||
const g = emptyMonthly()
|
||
for (let m = 1; m <= MONTHS; m++) {
|
||
g[`m${m}`] = (totalPersonal[`m${m}`] || 0) + (totalAfa[`m${m}`] || 0) + (totalSonstige[`m${m}`] || 0)
|
||
}
|
||
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(g), gesamtBetrieb.id])
|
||
gesamtBetrieb.values = g
|
||
}
|
||
|
||
// 7. Liquidität
|
||
const liquid = liquidRows.rows as FPLiquiditaet[]
|
||
const findLiq = (label: string) => liquid.find(r => r.row_label === label)
|
||
|
||
// Computed rows
|
||
const liqUmsatz = findLiq('Umsatzerlöse')
|
||
if (liqUmsatz) {
|
||
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(totalRevenue), liqUmsatz.id])
|
||
liqUmsatz.values = totalRevenue
|
||
}
|
||
const liqMaterial = findLiq('Materialaufwand')
|
||
if (liqMaterial) {
|
||
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(totalMaterial), liqMaterial.id])
|
||
liqMaterial.values = totalMaterial
|
||
}
|
||
const liqPersonal = findLiq('Personalkosten')
|
||
if (liqPersonal) {
|
||
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(totalPersonal), liqPersonal.id])
|
||
liqPersonal.values = totalPersonal
|
||
}
|
||
const liqSonstige = findLiq('Sonstige Kosten')
|
||
if (liqSonstige) {
|
||
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(totalSonstige), liqSonstige.id])
|
||
liqSonstige.values = totalSonstige
|
||
}
|
||
const liqInvest = findLiq('Investitionen')
|
||
if (liqInvest) {
|
||
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(totalInvest), liqInvest.id])
|
||
liqInvest.values = totalInvest
|
||
}
|
||
|
||
// 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'))
|
||
|
||
// Summe ERTRÄGE = ALL einzahlungen (dynamic — works regardless of how many rows exist)
|
||
if (sumEin) {
|
||
const s = emptyMonthly()
|
||
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 = ALL auszahlungen (dynamic)
|
||
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
|
||
}
|
||
|
||
// Ü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))
|
||
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), uebVorInv.id])
|
||
uebVorInv.values = s
|
||
}
|
||
|
||
// Ü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))
|
||
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), uebVorEnt.id])
|
||
uebVorEnt.values = s
|
||
}
|
||
|
||
// Ü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))
|
||
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), ueberschuss.id])
|
||
ueberschuss.values = s
|
||
}
|
||
|
||
// Rolling balance: LIQUIDITÄT[m] = LIQUIDITÄT[m-1] + ÜBERSCHUSS[m]
|
||
// ÜBERSCHUSS now includes ALL cash flows (operative + financing + repayments)
|
||
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
|
||
}
|
||
|
||
// 8. GuV — compute annual values
|
||
const guv: AnnualValues[] = []
|
||
const umsatzAnnual = annualSums(totalRevenue)
|
||
const materialAnnual = annualSums(totalMaterial)
|
||
const personalBruttoAnnual = annualSums(totalBrutto)
|
||
const personalSozialAnnual = annualSums(totalSozial)
|
||
const personalAnnual = annualSums(totalPersonal)
|
||
const afaAnnual = annualSums(totalAfa)
|
||
const sonstigeAnnual = annualSums(totalSonstige)
|
||
|
||
// Write GuV rows
|
||
// Rohergebnis = Gesamtleistung - Materialaufwand
|
||
const rohergebnis: AnnualValues = {}
|
||
for (let y = 2026; y <= 2030; y++) {
|
||
const k = `y${y}`
|
||
rohergebnis[k] = Math.round((umsatzAnnual[k] || 0) - (materialAnnual[k] || 0))
|
||
}
|
||
|
||
const guvUpdates: { label: string; values: AnnualValues }[] = [
|
||
{ label: 'Umsatzerlöse', values: umsatzAnnual },
|
||
{ label: 'Gesamtleistung', values: umsatzAnnual },
|
||
{ label: 'Summe Materialaufwand', values: materialAnnual },
|
||
{ label: 'Rohergebnis', values: rohergebnis },
|
||
{ label: 'Löhne und Gehälter', values: personalBruttoAnnual },
|
||
{ label: 'Soziale Abgaben', values: personalSozialAnnual },
|
||
{ label: 'Summe Personalaufwand', values: personalAnnual },
|
||
{ label: 'Abschreibungen', values: afaAnnual },
|
||
{ label: 'Sonst. betriebl. Aufwendungen', values: sonstigeAnnual },
|
||
]
|
||
|
||
for (const { label, values } of guvUpdates) {
|
||
await pool.query(
|
||
'UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3',
|
||
[JSON.stringify(values), scenarioId, label]
|
||
)
|
||
}
|
||
|
||
// EBIT (Betriebsergebnis)
|
||
const ebit: AnnualValues = {}
|
||
for (let y = 2026; y <= 2030; y++) {
|
||
const k = `y${y}`
|
||
ebit[k] = Math.round((umsatzAnnual[k] || 0) - (materialAnnual[k] || 0) - (personalAnnual[k] || 0) - (afaAnnual[k] || 0) - (sonstigeAnnual[k] || 0))
|
||
}
|
||
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ebit), scenarioId, 'EBIT'])
|
||
|
||
// Steuerberechnung (nur auf Gewinne, mit Verlustvortrag)
|
||
// Stockach 78333: Hebesatz 350%
|
||
// Gewerbesteuer = 3,5% × 3,5 = 12,25%
|
||
// Körperschaftsteuer = 15% + 5,5% Soli = 15,825%
|
||
// Gesamt: ~28,075%
|
||
const GEWERBESTEUER_RATE = 0.035 * 3.5 // 12,25%
|
||
const KOERPERSCHAFTSTEUER_RATE = 0.15 * 1.055 // 15,825% (inkl. Soli)
|
||
|
||
const gewerbesteuer: AnnualValues = {}
|
||
const koerperschaftsteuer: AnnualValues = {}
|
||
const steuernGesamt: AnnualValues = {}
|
||
const ergebnisNachSteuern: AnnualValues = {}
|
||
let verlustvortrag = 0 // kumulierter Verlustvortrag
|
||
|
||
for (let y = 2026; y <= 2030; y++) {
|
||
const k = `y${y}`
|
||
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)
|
||
let verrechenbar = 0
|
||
if (verlustvortrag > 0) {
|
||
if (gewinn <= 1000000) {
|
||
verrechenbar = Math.min(verlustvortrag, gewinn)
|
||
} else {
|
||
verrechenbar = Math.min(verlustvortrag, 1000000 + (gewinn - 1000000) * 0.6)
|
||
}
|
||
verlustvortrag -= verrechenbar
|
||
}
|
||
|
||
const zuVersteuern = Math.max(0, gewinn - verrechenbar)
|
||
const gst = Math.round(zuVersteuern * GEWERBESTEUER_RATE)
|
||
const kst = Math.round(zuVersteuern * KOERPERSCHAFTSTEUER_RATE)
|
||
|
||
gewerbesteuer[k] = gst
|
||
koerperschaftsteuer[k] = kst
|
||
steuernGesamt[k] = gst + kst
|
||
ergebnisNachSteuern[k] = Math.round(gewinn - gst - kst)
|
||
}
|
||
}
|
||
|
||
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(gewerbesteuer), scenarioId, 'Gewerbesteuer'])
|
||
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(koerperschaftsteuer), scenarioId, 'Körperschaftssteuer'])
|
||
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(steuernGesamt), scenarioId, 'Steuern gesamt'])
|
||
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'])
|
||
|
||
// Steuern auch in Liquidität eintragen (monatlich = 1/12 des Jahresbetrags)
|
||
const liqGewSt = findLiq('Gewerbesteuer')
|
||
const liqKSt = findLiq('Körperschaftsteuer')
|
||
if (liqGewSt) {
|
||
const v = emptyMonthly()
|
||
for (let y = 2026; y <= 2030; y++) {
|
||
const jahresBetrag = gewerbesteuer[`y${y}`] || 0
|
||
if (jahresBetrag > 0) {
|
||
const monatlich = Math.round(jahresBetrag / 12)
|
||
const startM = (y - 2026) * 12 + 1
|
||
for (let m = startM; m <= startM + 11 && m <= MONTHS; m++) {
|
||
v[`m${m}`] = monatlich
|
||
}
|
||
}
|
||
}
|
||
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(v), liqGewSt.id])
|
||
liqGewSt.values = v
|
||
}
|
||
if (liqKSt) {
|
||
const v = emptyMonthly()
|
||
for (let y = 2026; y <= 2030; y++) {
|
||
const jahresBetrag = koerperschaftsteuer[`y${y}`] || 0
|
||
if (jahresBetrag > 0) {
|
||
const monatlich = Math.round(jahresBetrag / 12)
|
||
const startM = (y - 2026) * 12 + 1
|
||
for (let m = startM; m <= startM + 11 && m <= MONTHS; m++) {
|
||
v[`m${m}`] = monatlich
|
||
}
|
||
}
|
||
}
|
||
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(v), liqKSt.id])
|
||
liqKSt.values = v
|
||
}
|
||
|
||
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: gesamtBetrieb?.values || emptyMonthly() },
|
||
liquiditaet: { rows: liquid, endstand: liquiditaet?.values || emptyMonthly() },
|
||
guv: [ebit],
|
||
}
|
||
}
|
||
|
||
// Import to fix type errors
|
||
type FPUmsatzerloese = import('./types').FPUmsatzerloese
|
||
type FPMaterialaufwand = import('./types').FPMaterialaufwand
|