From 08399bbb0a4c4c715cf77fc7d160153285e854da Mon Sep 17 00:00:00 2001 From: m Date: Tue, 31 Mar 2026 17:42:11 +0200 Subject: [PATCH] feat: add Patentprozesskostenrechner at /kosten/rechner Full patent litigation cost calculator supporting: - DE courts: LG, OLG, BGH (NZB/Revision), BPatG, BGH nullity - UPC: first instance + appeal with SME reduction - All 5 GKG/RVG fee schedule versions (2005-2025) - Per-instance config: attorneys, patent attorneys, hearing, clients - Live cost breakdown with per-instance detail cards - DE vs UPC comparison bar chart - Streitwert slider with presets (500 - 30M EUR) - German labels, EUR formatting, responsive layout New files: - lib/costs/types.ts, fee-tables.ts, calculator.ts (pure calculation) - components/costs/CostCalculator, InstanceCard, UPCCard, CostSummary, CostComparison - app/(app)/kosten/rechner/page.tsx Sidebar: added "Kostenrechner" with Calculator icon between Berichte and AI Analyse. Types: added FeeCalculateRequest/Response to lib/types.ts. --- .../src/app/(app)/kosten/rechner/page.tsx | 24 ++ .../src/components/costs/CostCalculator.tsx | 323 ++++++++++++++++++ .../src/components/costs/CostComparison.tsx | 83 +++++ frontend/src/components/costs/CostSummary.tsx | 229 +++++++++++++ .../src/components/costs/InstanceCard.tsx | 168 +++++++++ frontend/src/components/costs/UPCCard.tsx | 115 +++++++ frontend/src/components/layout/Sidebar.tsx | 2 + frontend/src/lib/costs/calculator.ts | 276 +++++++++++++++ frontend/src/lib/costs/fee-tables.ts | 239 +++++++++++++ frontend/src/lib/costs/types.ts | 132 +++++++ frontend/src/lib/types.ts | 38 +++ 11 files changed, 1629 insertions(+) create mode 100644 frontend/src/app/(app)/kosten/rechner/page.tsx create mode 100644 frontend/src/components/costs/CostCalculator.tsx create mode 100644 frontend/src/components/costs/CostComparison.tsx create mode 100644 frontend/src/components/costs/CostSummary.tsx create mode 100644 frontend/src/components/costs/InstanceCard.tsx create mode 100644 frontend/src/components/costs/UPCCard.tsx create mode 100644 frontend/src/lib/costs/calculator.ts create mode 100644 frontend/src/lib/costs/fee-tables.ts create mode 100644 frontend/src/lib/costs/types.ts diff --git a/frontend/src/app/(app)/kosten/rechner/page.tsx b/frontend/src/app/(app)/kosten/rechner/page.tsx new file mode 100644 index 0000000..8d0b381 --- /dev/null +++ b/frontend/src/app/(app)/kosten/rechner/page.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { CostCalculator } from "@/components/costs/CostCalculator"; +import { Calculator } from "lucide-react"; + +export default function KostenrechnerPage() { + return ( +
+
+
+ +

+ Patentprozesskostenrechner +

+
+

+ Berechnung der Verfahrenskosten für deutsche Patentverfahren und UPC +

+
+ + +
+ ); +} diff --git a/frontend/src/components/costs/CostCalculator.tsx b/frontend/src/components/costs/CostCalculator.tsx new file mode 100644 index 0000000..56d6b06 --- /dev/null +++ b/frontend/src/components/costs/CostCalculator.tsx @@ -0,0 +1,323 @@ +"use client"; + +import { useState, useMemo } from "react"; +import type { + DEInstance, + UPCInstance, + InstanceConfig, + UPCConfig, + VatRate, + Jurisdiction, + CalculatorResult, +} from "@/lib/costs/types"; +import { computeDEInstance, computeUPCInstance } from "@/lib/costs/calculator"; +import { + DE_INFRINGEMENT_INSTANCES, + DE_NULLITY_INSTANCES, +} from "@/lib/costs/fee-tables"; +import { InstanceCard } from "./InstanceCard"; +import { UPCCard } from "./UPCCard"; +import { CostSummary } from "./CostSummary"; +import { CostComparison } from "./CostComparison"; + +const DEFAULT_DE_CONFIG: InstanceConfig = { + enabled: false, + feeVersion: "Aktuell", + numAttorneys: 1, + numPatentAttorneys: 1, + oralHearing: true, + numClients: 1, +}; + +const DEFAULT_UPC_CONFIG: UPCConfig = { + enabled: false, + feeVersion: "2026", + isSME: false, + includeRevocation: false, +}; + +function makeDefaultDEInstances(): Record { + return { + LG: { ...DEFAULT_DE_CONFIG, enabled: true }, + OLG: { ...DEFAULT_DE_CONFIG }, + BGH_NZB: { ...DEFAULT_DE_CONFIG }, + BGH_REV: { ...DEFAULT_DE_CONFIG }, + BPatG: { ...DEFAULT_DE_CONFIG }, + BGH_NULLITY: { ...DEFAULT_DE_CONFIG }, + }; +} + +function makeDefaultUPCInstances(): Record { + return { + UPC_FIRST: { ...DEFAULT_UPC_CONFIG, enabled: true }, + UPC_APPEAL: { ...DEFAULT_UPC_CONFIG }, + }; +} + +const STREITWERT_PRESETS = [ + 100_000, 250_000, 500_000, 1_000_000, 3_000_000, 5_000_000, 10_000_000, 30_000_000, +]; + +export function CostCalculator() { + const [streitwert, setStreitwert] = useState(1_000_000); + const [streitwertInput, setStreitwertInput] = useState("1.000.000"); + const [vatRate, setVatRate] = useState(0.19); + const [jurisdiction, setJurisdiction] = useState("DE"); + const [deInstances, setDEInstances] = useState(makeDefaultDEInstances); + const [upcInstances, setUPCInstances] = useState(makeDefaultUPCInstances); + + // Parse formatted number input + function handleStreitwertInput(raw: string) { + setStreitwertInput(raw); + const cleaned = raw.replace(/\./g, "").replace(/,/g, "."); + const num = parseFloat(cleaned); + if (!isNaN(num) && num >= 500 && num <= 30_000_000) { + setStreitwert(num); + } + } + + function handleStreitwertBlur() { + setStreitwertInput( + new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 }).format(streitwert), + ); + } + + function handleSliderChange(value: number) { + setStreitwert(value); + setStreitwertInput( + new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 }).format(value), + ); + } + + // All DE metadata (infringement + nullity) + const allDEMeta = useMemo( + () => [...DE_INFRINGEMENT_INSTANCES, ...DE_NULLITY_INSTANCES], + [], + ); + + // Compute results reactively + const results: CalculatorResult = useMemo(() => { + const deResults = allDEMeta.map((meta) => { + const key = meta.key as DEInstance; + return computeDEInstance(streitwert, deInstances[key], meta, vatRate); + }); + + const upcFirst = computeUPCInstance( + streitwert, + upcInstances.UPC_FIRST, + "UPC_FIRST", + upcInstances.UPC_FIRST.includeRevocation, + ); + const upcAppeal = computeUPCInstance( + streitwert, + upcInstances.UPC_APPEAL, + "UPC_APPEAL", + upcInstances.UPC_APPEAL.includeRevocation, + ); + const upcResults = [upcFirst, upcAppeal]; + + const deTotal = deResults.reduce((sum, r) => sum + r.instanceTotal, 0); + const upcTotal = upcResults.reduce((sum, r) => sum + r.instanceTotal, 0); + + return { + deResults, + upcResults, + deTotal: Math.round(deTotal * 100) / 100, + upcTotal: Math.round(upcTotal * 100) / 100, + grandTotal: Math.round((deTotal + upcTotal) * 100) / 100, + }; + }, [streitwert, vatRate, deInstances, upcInstances, allDEMeta]); + + const showDE = jurisdiction === "DE" || jurisdiction === "UPC"; + const showUPC = jurisdiction === "UPC"; + const showComparison = + showDE && + showUPC && + results.deResults.some((r) => r.enabled) && + results.upcResults.some((r) => r.enabled); + + return ( +
+ {/* Left: Inputs */} +
+ {/* Global inputs card */} +
+

+ Grundeinstellungen +

+ + {/* Streitwert */} +
+ +
+ handleStreitwertInput(e.target.value)} + onBlur={handleStreitwertBlur} + className="w-40 rounded-md border border-neutral-200 bg-white px-3 py-2 text-right text-sm font-medium text-neutral-900 tabular-nums focus:border-neutral-400 focus:outline-none focus:ring-1 focus:ring-neutral-400" + /> + EUR +
+ handleSliderChange(parseInt(e.target.value))} + className="mt-2 w-full accent-neutral-700" + /> +
+ 500 + 30 Mio. +
+ {/* Presets */} +
+ {STREITWERT_PRESETS.map((v) => ( + + ))} +
+
+ + {/* VAT + Jurisdiction row */} +
+
+ + +
+ +
+ +
+ + +
+
+
+
+ + {/* DE instances */} + {showDE && ( +
+

+ Verletzungsverfahren +

+ {DE_INFRINGEMENT_INSTANCES.map((meta) => ( + + setDEInstances((prev) => ({ ...prev, [meta.key as DEInstance]: c })) + } + /> + ))} + +

+ Nichtigkeitsverfahren +

+ {DE_NULLITY_INSTANCES.map((meta) => ( + + setDEInstances((prev) => ({ ...prev, [meta.key as DEInstance]: c })) + } + /> + ))} +
+ )} + + {/* UPC instances */} + {showUPC && ( +
+

+ Einheitliches Patentgericht (UPC) +

+ + setUPCInstances((prev) => ({ ...prev, UPC_FIRST: c })) + } + showRevocation + /> + + setUPCInstances((prev) => ({ ...prev, UPC_APPEAL: c })) + } + /> +
+ )} +
+ + {/* Right: Results */} +
+ + + {showComparison && ( + + )} + + {/* Print note */} +

+ Alle Angaben ohne Gewähr. Berechnung basiert auf GKG/RVG/PatKostG bzw. UPC-Gebührenordnung. +

+
+
+ ); +} diff --git a/frontend/src/components/costs/CostComparison.tsx b/frontend/src/components/costs/CostComparison.tsx new file mode 100644 index 0000000..7d3b93b --- /dev/null +++ b/frontend/src/components/costs/CostComparison.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { formatEUR } from "@/lib/costs/calculator"; + +interface Props { + deTotal: number; + upcTotal: number; + deLabel?: string; + upcLabel?: string; +} + +export function CostComparison({ + deTotal, + upcTotal, + deLabel = "Deutsche Gerichte", + upcLabel = "UPC", +}: Props) { + if (deTotal === 0 && upcTotal === 0) return null; + + const maxValue = Math.max(deTotal, upcTotal); + const dePercent = maxValue > 0 ? (deTotal / maxValue) * 100 : 0; + const upcPercent = maxValue > 0 ? (upcTotal / maxValue) * 100 : 0; + const diff = upcTotal - deTotal; + const diffPercent = deTotal > 0 ? ((diff / deTotal) * 100).toFixed(0) : "—"; + + return ( +
+

+ Kostenvergleich DE vs. UPC +

+ +
+ {/* DE bar */} +
+
+ {deLabel} + + {formatEUR(deTotal)} + +
+
+
+
+
+ + {/* UPC bar */} +
+
+ {upcLabel} + + {formatEUR(upcTotal)} + +
+
+
+
+
+ + {/* Difference */} + {deTotal > 0 && upcTotal > 0 && ( +
+ Differenz + 0 ? "text-red-600" : diff < 0 ? "text-green-600" : "text-neutral-600" + }`} + > + {diff > 0 ? "+" : ""} + {formatEUR(diff)} ({diff > 0 ? "+" : ""} + {diffPercent}%) + +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/costs/CostSummary.tsx b/frontend/src/components/costs/CostSummary.tsx new file mode 100644 index 0000000..b11a6d4 --- /dev/null +++ b/frontend/src/components/costs/CostSummary.tsx @@ -0,0 +1,229 @@ +"use client"; + +import type { InstanceResult, UPCInstanceResult } from "@/lib/costs/types"; +import { formatEUR } from "@/lib/costs/calculator"; + +interface Props { + deResults: InstanceResult[]; + upcResults: UPCInstanceResult[]; + deTotal: number; + upcTotal: number; +} + +function DEInstanceBreakdown({ result }: { result: InstanceResult }) { + if (!result.enabled) return null; + + return ( +
+

{result.label}

+ +
+ {/* Court fees */} +
+ Gerichtskosten + + {formatEUR(result.gerichtskosten)} + +
+ + {/* Attorney fees */} + {result.perAttorney && ( +
+
+ + Rechtsanwaltskosten + {result.attorneyTotal !== result.perAttorney.totalBrutto && ( + + {" "} + ({Math.round(result.attorneyTotal / result.perAttorney.totalBrutto)}x) + + )} + + + {formatEUR(result.attorneyTotal)} + +
+ {/* Detail breakdown */} +
+
+ Verfahrensgebühr + {formatEUR(result.perAttorney.verfahrensgebuehr)} +
+ {result.perAttorney.erhoehungsgebuehr > 0 && ( +
+ Erhöhungsgebühr + {formatEUR(result.perAttorney.erhoehungsgebuehr)} +
+ )} + {result.perAttorney.terminsgebuehr > 0 && ( +
+ Terminsgebühr + {formatEUR(result.perAttorney.terminsgebuehr)} +
+ )} +
+ Auslagenpauschale + {formatEUR(result.perAttorney.auslagenpauschale)} +
+ {result.perAttorney.vat > 0 && ( +
+ USt. + {formatEUR(result.perAttorney.vat)} +
+ )} +
+
+ )} + + {/* Patent attorney fees */} + {result.perPatentAttorney && ( +
+
+ + Patentanwaltskosten + {result.patentAttorneyTotal !== result.perPatentAttorney.totalBrutto && ( + + {" "} + ({Math.round(result.patentAttorneyTotal / result.perPatentAttorney.totalBrutto)}x) + + )} + + + {formatEUR(result.patentAttorneyTotal)} + +
+
+ )} + + {/* Instance total */} +
+ Zwischensumme + + {formatEUR(result.instanceTotal)} + +
+
+
+ ); +} + +function UPCInstanceBreakdown({ result }: { result: UPCInstanceResult }) { + if (!result.enabled) return null; + + return ( +
+

{result.label}

+ +
+
+ Festgebühr + + {formatEUR(result.fixedFee)} + +
+ + {result.valueBasedFee > 0 && ( +
+ Streitwertabhängige Gebühr + + {formatEUR(result.valueBasedFee)} + +
+ )} + +
+ Gerichtskosten gesamt + + {formatEUR(result.courtFeesTotal)} + +
+ + {result.courtFeesSME !== result.courtFeesTotal && ( +
+ Gerichtskosten (KMU) + {formatEUR(result.courtFeesSME)} +
+ )} + +
+ Erstattungsfähige Kosten (Deckel) + + {formatEUR(result.recoverableCostsCeiling)} + +
+ +
+ Gesamtkostenrisiko + + {formatEUR(result.instanceTotal)} + +
+
+
+ ); +} + +export function CostSummary({ deResults, upcResults, deTotal, upcTotal }: Props) { + const hasDE = deResults.some((r) => r.enabled); + const hasUPC = upcResults.some((r) => r.enabled); + + if (!hasDE && !hasUPC) { + return ( +
+

+ Mindestens eine Instanz aktivieren, um Kosten zu berechnen. +

+
+ ); + } + + return ( +
+ {/* DE results */} + {hasDE && ( +
+

+ Deutsche Gerichte +

+ {deResults.map((r) => ( + + ))} + {deResults.filter((r) => r.enabled).length > 1 && ( +
+ Gesamtkosten DE + {formatEUR(deTotal)} +
+ )} +
+ )} + + {/* UPC results */} + {hasUPC && ( +
+

+ Einheitliches Patentgericht (UPC) +

+ {upcResults.map((r) => ( + + ))} + {upcResults.filter((r) => r.enabled).length > 1 && ( +
+ Gesamtkosten UPC + {formatEUR(upcTotal)} +
+ )} +
+ )} + + {/* Grand total when both */} + {hasDE && hasUPC && ( +
+ Gesamtkosten + + {formatEUR(deTotal + upcTotal)} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/costs/InstanceCard.tsx b/frontend/src/components/costs/InstanceCard.tsx new file mode 100644 index 0000000..a5850a7 --- /dev/null +++ b/frontend/src/components/costs/InstanceCard.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { ChevronDown, ChevronRight } from "lucide-react"; +import { useState } from "react"; +import type { InstanceConfig, FeeScheduleVersion, InstanceMeta } from "@/lib/costs/types"; +import { FEE_SCHEDULES } from "@/lib/costs/fee-tables"; + +interface Props { + meta: InstanceMeta; + config: InstanceConfig; + onChange: (config: InstanceConfig) => void; +} + +const VERSION_OPTIONS: { value: FeeScheduleVersion; label: string }[] = Object.entries( + FEE_SCHEDULES, +).map(([key, entry]) => ({ + value: key as FeeScheduleVersion, + label: entry.label, +})); + +export function InstanceCard({ meta, config, onChange }: Props) { + const [expanded, setExpanded] = useState(config.enabled); + + function update(patch: Partial) { + onChange({ ...config, ...patch }); + } + + return ( +
+ {/* Header */} +
+ + {config.enabled && ( + + )} +
+ + {/* Settings */} + {config.enabled && expanded && ( +
+
+ {/* Fee version */} +
+ + +
+ + {/* Number of attorneys */} +
+ + + update({ numAttorneys: Math.max(0, parseInt(e.target.value) || 0) }) + } + className="w-full rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-900 focus:border-neutral-400 focus:outline-none focus:ring-1 focus:ring-neutral-400" + /> +
+ + {/* Number of patent attorneys */} + {meta.hasPatentAttorneys && ( +
+ + + update({ + numPatentAttorneys: Math.max(0, parseInt(e.target.value) || 0), + }) + } + className="w-full rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-900 focus:border-neutral-400 focus:outline-none focus:ring-1 focus:ring-neutral-400" + /> +
+ )} + + {/* Oral hearing */} +
+ +
+ + {/* Number of clients */} +
+ + + update({ numClients: Math.max(1, parseInt(e.target.value) || 1) }) + } + className="w-full rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-900 focus:border-neutral-400 focus:outline-none focus:ring-1 focus:ring-neutral-400" + /> +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/costs/UPCCard.tsx b/frontend/src/components/costs/UPCCard.tsx new file mode 100644 index 0000000..bce746a --- /dev/null +++ b/frontend/src/components/costs/UPCCard.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { ChevronDown, ChevronRight } from "lucide-react"; +import { useState } from "react"; +import type { UPCConfig } from "@/lib/costs/types"; + +interface Props { + label: string; + config: UPCConfig; + onChange: (config: UPCConfig) => void; + showRevocation?: boolean; +} + +export function UPCCard({ label, config, onChange, showRevocation }: Props) { + const [expanded, setExpanded] = useState(config.enabled); + + function update(patch: Partial) { + onChange({ ...config, ...patch }); + } + + return ( +
+
+ + {config.enabled && ( + + )} +
+ + {config.enabled && expanded && ( +
+
+
+ + +
+ +
+ +
+ + {showRevocation && ( +
+ +
+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index ebd2664..5ada912 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -9,6 +9,7 @@ import { Calendar, Brain, BarChart3, + Calculator, Settings, Menu, X, @@ -29,6 +30,7 @@ const allNavigation: NavItem[] = [ { name: "Fristen", href: "/fristen", icon: Clock }, { name: "Termine", href: "/termine", icon: Calendar }, { name: "Berichte", href: "/berichte", icon: BarChart3 }, + { name: "Kostenrechner", href: "/kosten/rechner", icon: Calculator }, { name: "AI Analyse", href: "/ai/extract", icon: Brain, permission: "ai_extraction" }, { name: "Einstellungen", href: "/einstellungen", icon: Settings, permission: "manage_settings" }, ]; diff --git a/frontend/src/lib/costs/calculator.ts b/frontend/src/lib/costs/calculator.ts new file mode 100644 index 0000000..469e130 --- /dev/null +++ b/frontend/src/lib/costs/calculator.ts @@ -0,0 +1,276 @@ +import type { + FeeScheduleVersion, + FeeBracket, + InstanceConfig, + InstanceMeta, + InstanceResult, + AttorneyBreakdown, + VatRate, + UPCConfig, + UPCInstance, + UPCInstanceResult, + UPCFeeBracket, + UPCRecoverableCost, +} from "./types"; +import { isAlias } from "./types"; +import { FEE_SCHEDULES, CONSTANTS, UPC_FEES } from "./fee-tables"; + +/** Resolve alias to actual fee schedule brackets */ +function resolveBrackets(version: FeeScheduleVersion): FeeBracket[] { + const entry = FEE_SCHEDULES[version]; + if (isAlias(entry)) { + return resolveBrackets(entry.aliasOf); + } + return entry.brackets; +} + +/** + * Compute the base fee (1.0x) using the step-based accumulator algorithm. + * @param isRVG - true for RVG (attorney), false for GKG (court) + */ +export function computeBaseFee( + streitwert: number, + isRVG: boolean, + version: FeeScheduleVersion, +): number { + const brackets = resolveBrackets(version); + let remaining = streitwert; + let fee = 0; + let lowerBound = 0; + + for (const bracket of brackets) { + const [upperBound, stepSize, gkgInc, rvgInc] = bracket; + const increment = isRVG ? rvgInc : gkgInc; + const bracketSize = upperBound === Infinity ? remaining : upperBound - lowerBound; + const portionInBracket = Math.min(remaining, bracketSize); + + if (portionInBracket <= 0) break; + + // First bracket: the base fee comes from the first step + if (lowerBound === 0) { + // The minimum fee is the increment for the first bracket + fee += increment; + const stepsAfterFirst = Math.max(0, Math.ceil((portionInBracket - stepSize) / stepSize)); + fee += stepsAfterFirst * increment; + } else { + const steps = Math.ceil(portionInBracket / stepSize); + fee += steps * increment; + } + + remaining -= portionInBracket; + lowerBound = upperBound; + if (remaining <= 0) break; + } + + return fee; +} + +/** Compute attorney fees for a single attorney */ +function computeAttorneyFees( + streitwert: number, + version: FeeScheduleVersion, + vgFactor: number, + tgFactor: number, + oralHearing: boolean, + numClients: number, + vatRate: VatRate, +): AttorneyBreakdown { + const baseRVG = computeBaseFee(streitwert, true, version); + + const verfahrensgebuehr = vgFactor * baseRVG; + const erhoehungsgebuehr = + numClients > 1 + ? Math.min((numClients - 1) * CONSTANTS.erhoehungsfaktor, CONSTANTS.erhoehungsfaktorMax) * + baseRVG + : 0; + const terminsgebuehr = oralHearing ? tgFactor * baseRVG : 0; + const auslagenpauschale = CONSTANTS.auslagenpauschale; + + const subtotalNetto = + verfahrensgebuehr + erhoehungsgebuehr + terminsgebuehr + auslagenpauschale; + const vat = subtotalNetto * vatRate; + const totalBrutto = subtotalNetto + vat; + + return { + verfahrensgebuehr: Math.round(verfahrensgebuehr * 100) / 100, + erhoehungsgebuehr: Math.round(erhoehungsgebuehr * 100) / 100, + terminsgebuehr: Math.round(terminsgebuehr * 100) / 100, + auslagenpauschale, + subtotalNetto: Math.round(subtotalNetto * 100) / 100, + vat: Math.round(vat * 100) / 100, + totalBrutto: Math.round(totalBrutto * 100) / 100, + }; +} + +/** Compute all costs for a single DE instance */ +export function computeDEInstance( + streitwert: number, + config: InstanceConfig, + meta: InstanceMeta, + vatRate: VatRate, +): InstanceResult { + if (!config.enabled) { + return { + instance: meta.key, + label: meta.label, + enabled: false, + gerichtskosten: 0, + perAttorney: null, + attorneyTotal: 0, + perPatentAttorney: null, + patentAttorneyTotal: 0, + instanceTotal: 0, + }; + } + + // Court fees: base fee × factor + // PatKostG (BPatG nullity) uses the same step-based lookup from the GKG column + const baseFee = computeBaseFee(streitwert, false, config.feeVersion); + const gerichtskosten = + meta.feeBasis === "fixed" + ? meta.fixedCourtFee ?? 0 + : Math.round(meta.courtFeeFactor * baseFee * 100) / 100; + + // Attorney (RA) fees + const perAttorney = + config.numAttorneys > 0 + ? computeAttorneyFees( + streitwert, + config.feeVersion, + meta.raVGFactor, + meta.raTGFactor, + config.oralHearing, + config.numClients, + vatRate, + ) + : null; + const attorneyTotal = perAttorney + ? Math.round(perAttorney.totalBrutto * config.numAttorneys * 100) / 100 + : 0; + + // Patent attorney (PA) fees + const perPatentAttorney = + meta.hasPatentAttorneys && config.numPatentAttorneys > 0 + ? computeAttorneyFees( + streitwert, + config.feeVersion, + meta.paVGFactor, + meta.paTGFactor, + config.oralHearing, + config.numClients, + vatRate, + ) + : null; + const patentAttorneyTotal = perPatentAttorney + ? Math.round(perPatentAttorney.totalBrutto * config.numPatentAttorneys * 100) / 100 + : 0; + + const instanceTotal = + Math.round((gerichtskosten + attorneyTotal + patentAttorneyTotal) * 100) / 100; + + return { + instance: meta.key, + label: meta.label, + enabled: true, + gerichtskosten, + perAttorney, + attorneyTotal, + perPatentAttorney, + patentAttorneyTotal, + instanceTotal, + }; +} + +/** Look up a value-based fee from UPC bracket table */ +function lookupUPCValueFee(streitwert: number, brackets: readonly UPCFeeBracket[]): number { + for (const bracket of brackets) { + if (bracket.maxValue === null || streitwert <= bracket.maxValue) { + return bracket.fee; + } + } + return brackets[brackets.length - 1].fee; +} + +/** Look up recoverable costs ceiling */ +function lookupRecoverableCosts( + streitwert: number, + table: readonly UPCRecoverableCost[], +): number { + for (const entry of table) { + if (entry.maxValue === null || streitwert <= entry.maxValue) { + return entry.ceiling; + } + } + return table[table.length - 1].ceiling; +} + +/** Compute UPC instance costs */ +export function computeUPCInstance( + streitwert: number, + config: UPCConfig, + instance: UPCInstance, + includeRevocation: boolean, +): UPCInstanceResult { + const label = + instance === "UPC_FIRST" ? "UPC (1. Instanz)" : "UPC (Berufung)"; + + if (!config.enabled) { + return { + instance, + label, + enabled: false, + fixedFee: 0, + valueBasedFee: 0, + courtFeesTotal: 0, + courtFeesSME: 0, + recoverableCostsCeiling: 0, + instanceTotal: 0, + }; + } + + const feeData = UPC_FEES[config.feeVersion]; + + // Fixed fee for infringement + let fixedFee = feeData.fixedFees.infringement; + + // Add revocation fee if counterclaim included + if (includeRevocation) { + fixedFee += feeData.fixedFees.revocation; + } + + // Value-based fee (only for infringement-type actions with Streitwert > 500k) + const valueBasedFee = lookupUPCValueFee(streitwert, feeData.valueBased); + + const courtFeesTotal = fixedFee + valueBasedFee; + const courtFeesSME = Math.round(courtFeesTotal * (1 - feeData.smReduction)); + + const recoverableCostsCeiling = lookupRecoverableCosts( + streitwert, + feeData.recoverableCosts, + ); + + // Total cost risk = court fees + recoverable costs ceiling + const instanceTotal = (config.isSME ? courtFeesSME : courtFeesTotal) + recoverableCostsCeiling; + + return { + instance, + label, + enabled: true, + fixedFee, + valueBasedFee, + courtFeesTotal, + courtFeesSME, + recoverableCostsCeiling, + instanceTotal, + }; +} + +/** Format a number as EUR with thousand separators */ +export function formatEUR(value: number): string { + return new Intl.NumberFormat("de-DE", { + style: "currency", + currency: "EUR", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value); +} diff --git a/frontend/src/lib/costs/fee-tables.ts b/frontend/src/lib/costs/fee-tables.ts new file mode 100644 index 0000000..7ef690a --- /dev/null +++ b/frontend/src/lib/costs/fee-tables.ts @@ -0,0 +1,239 @@ +import type { + FeeScheduleVersion, + FeeScheduleEntry, + InstanceMeta, + UPCFeeBracket, + UPCRecoverableCost, +} from "./types"; + +// Fee schedules: [upperBound, stepSize, gkgIncrement, rvgIncrement] +export const FEE_SCHEDULES: Record = { + "2005": { + label: "GKG/RVG 2006-09-01", + validFrom: "2006-09-01", + brackets: [ + [300, 300, 25, 25], + [1500, 300, 10, 20], + [5000, 500, 8, 28], + [10000, 1000, 15, 37], + [25000, 3000, 23, 40], + [50000, 5000, 29, 72], + [200000, 15000, 100, 77], + [500000, 30000, 150, 118], + [Infinity, 50000, 150, 150], + ], + }, + "2013": { + label: "GKG/RVG 2013-08-01", + validFrom: "2013-08-01", + brackets: [ + [500, 300, 35, 45], + [2000, 500, 18, 35], + [10000, 1000, 19, 51], + [25000, 3000, 26, 46], + [50000, 5000, 35, 75], + [200000, 15000, 120, 85], + [500000, 30000, 179, 120], + [Infinity, 50000, 180, 150], + ], + }, + "2021": { + label: "GKG/RVG 2021-01-01", + validFrom: "2021-01-01", + brackets: [ + [500, 300, 38, 49], + [2000, 500, 20, 39], + [10000, 1000, 21, 56], + [25000, 3000, 29, 52], + [50000, 5000, 38, 81], + [200000, 15000, 132, 94], + [500000, 30000, 198, 132], + [Infinity, 50000, 198, 165], + ], + }, + "2025": { + label: "GKG/RVG 2025-06-01", + validFrom: "2025-06-01", + brackets: [ + [500, 300, 40, 51.5], + [2000, 500, 21, 41.5], + [10000, 1000, 22.5, 59.5], + [25000, 3000, 30.5, 55], + [50000, 5000, 40.5, 86], + [200000, 15000, 140, 99.5], + [500000, 30000, 210, 140], + [Infinity, 50000, 210, 175], + ], + }, + Aktuell: { + label: "Aktuell (= 2025-06-01)", + validFrom: "2025-06-01", + aliasOf: "2025", + }, +}; + +export const CONSTANTS = { + erhoehungsfaktor: 0.3, + erhoehungsfaktorMax: 2.0, + auslagenpauschale: 20, +} as const; + +// DE instance metadata with fee multipliers +export const DE_INFRINGEMENT_INSTANCES: InstanceMeta[] = [ + { + key: "LG", + label: "LG (Verletzung 1. Instanz)", + courtFeeFactor: 3.0, + feeBasis: "GKG", + raVGFactor: 1.3, + raTGFactor: 1.2, + paVGFactor: 1.3, + paTGFactor: 1.2, + hasPatentAttorneys: true, + }, + { + key: "OLG", + label: "OLG (Berufung)", + courtFeeFactor: 4.0, + feeBasis: "GKG", + raVGFactor: 1.6, + raTGFactor: 1.2, + paVGFactor: 1.6, + paTGFactor: 1.2, + hasPatentAttorneys: true, + }, + { + key: "BGH_NZB", + label: "BGH (Nichtzulassungsbeschwerde)", + courtFeeFactor: 2.0, + feeBasis: "GKG", + raVGFactor: 2.3, + raTGFactor: 1.2, + paVGFactor: 1.6, + paTGFactor: 1.2, + hasPatentAttorneys: true, + }, + { + key: "BGH_REV", + label: "BGH (Revision)", + courtFeeFactor: 5.0, + feeBasis: "GKG", + raVGFactor: 2.3, + raTGFactor: 1.5, + paVGFactor: 1.6, + paTGFactor: 1.5, + hasPatentAttorneys: true, + }, +]; + +export const DE_NULLITY_INSTANCES: InstanceMeta[] = [ + { + key: "BPatG", + label: "BPatG (Nichtigkeitsverfahren)", + courtFeeFactor: 4.5, + feeBasis: "PatKostG", + raVGFactor: 1.3, + raTGFactor: 1.2, + paVGFactor: 1.3, + paTGFactor: 1.2, + hasPatentAttorneys: true, + }, + { + key: "BGH_NULLITY", + label: "BGH (Nichtigkeitsberufung)", + courtFeeFactor: 6.0, + feeBasis: "GKG", + raVGFactor: 1.6, + raTGFactor: 1.5, + paVGFactor: 1.6, + paTGFactor: 1.5, + hasPatentAttorneys: true, + }, +]; + +// UPC fee data +export const UPC_FEES = { + pre2026: { + label: "UPC (vor 2026)", + fixedFees: { + infringement: 11000, + revocation: 20000, + }, + valueBased: [ + { maxValue: 500000, fee: 0 }, + { maxValue: 750000, fee: 2500 }, + { maxValue: 1000000, fee: 4000 }, + { maxValue: 1500000, fee: 8000 }, + { maxValue: 2000000, fee: 13000 }, + { maxValue: 3000000, fee: 20000 }, + { maxValue: 4000000, fee: 26000 }, + { maxValue: 5000000, fee: 32000 }, + { maxValue: 6000000, fee: 39000 }, + { maxValue: 7000000, fee: 46000 }, + { maxValue: 8000000, fee: 52000 }, + { maxValue: 9000000, fee: 58000 }, + { maxValue: 10000000, fee: 65000 }, + { maxValue: 15000000, fee: 75000 }, + { maxValue: 20000000, fee: 100000 }, + { maxValue: 25000000, fee: 125000 }, + { maxValue: 30000000, fee: 150000 }, + { maxValue: 50000000, fee: 250000 }, + { maxValue: null, fee: 325000 }, + ] as UPCFeeBracket[], + recoverableCosts: [ + { maxValue: 250000, ceiling: 38000 }, + { maxValue: 500000, ceiling: 56000 }, + { maxValue: 1000000, ceiling: 112000 }, + { maxValue: 2000000, ceiling: 200000 }, + { maxValue: 4000000, ceiling: 400000 }, + { maxValue: 8000000, ceiling: 600000 }, + { maxValue: 16000000, ceiling: 800000 }, + { maxValue: 30000000, ceiling: 1200000 }, + { maxValue: 50000000, ceiling: 1500000 }, + { maxValue: null, ceiling: 2000000 }, + ] as UPCRecoverableCost[], + smReduction: 0.4, + }, + "2026": { + label: "UPC (ab 2026)", + fixedFees: { + infringement: 14600, + revocation: 26500, + }, + // Estimated ~32% increase on pre-2026 values + valueBased: [ + { maxValue: 500000, fee: 0 }, + { maxValue: 750000, fee: 3300 }, + { maxValue: 1000000, fee: 5300 }, + { maxValue: 1500000, fee: 10600 }, + { maxValue: 2000000, fee: 17200 }, + { maxValue: 3000000, fee: 26400 }, + { maxValue: 4000000, fee: 34300 }, + { maxValue: 5000000, fee: 42200 }, + { maxValue: 6000000, fee: 51500 }, + { maxValue: 7000000, fee: 60700 }, + { maxValue: 8000000, fee: 68600 }, + { maxValue: 9000000, fee: 76600 }, + { maxValue: 10000000, fee: 85800 }, + { maxValue: 15000000, fee: 99000 }, + { maxValue: 20000000, fee: 132000 }, + { maxValue: 25000000, fee: 165000 }, + { maxValue: 30000000, fee: 198000 }, + { maxValue: 50000000, fee: 330000 }, + { maxValue: null, fee: 429000 }, + ] as UPCFeeBracket[], + recoverableCosts: [ + { maxValue: 250000, ceiling: 38000 }, + { maxValue: 500000, ceiling: 56000 }, + { maxValue: 1000000, ceiling: 112000 }, + { maxValue: 2000000, ceiling: 200000 }, + { maxValue: 4000000, ceiling: 400000 }, + { maxValue: 8000000, ceiling: 600000 }, + { maxValue: 16000000, ceiling: 800000 }, + { maxValue: 30000000, ceiling: 1200000 }, + { maxValue: 50000000, ceiling: 1500000 }, + { maxValue: null, ceiling: 2000000 }, + ] as UPCRecoverableCost[], + smReduction: 0.5, + }, +} as const; diff --git a/frontend/src/lib/costs/types.ts b/frontend/src/lib/costs/types.ts new file mode 100644 index 0000000..0d9005e --- /dev/null +++ b/frontend/src/lib/costs/types.ts @@ -0,0 +1,132 @@ +// Fee calculation types for the Patentprozesskostenrechner + +export type FeeScheduleVersion = "2005" | "2013" | "2021" | "2025" | "Aktuell"; + +export type Jurisdiction = "DE" | "UPC"; + +export type VatRate = 0 | 0.16 | 0.19; + +/** [upperBound, stepSize, gkgIncrement, rvgIncrement] */ +export type FeeBracket = [number, number, number, number]; + +export interface FeeSchedule { + label: string; + validFrom: string; + brackets: FeeBracket[]; +} + +export interface FeeScheduleAlias { + label: string; + validFrom: string; + aliasOf: FeeScheduleVersion; +} + +export type FeeScheduleEntry = FeeSchedule | FeeScheduleAlias; + +export function isAlias(entry: FeeScheduleEntry): entry is FeeScheduleAlias { + return "aliasOf" in entry; +} + +// DE instance types +export type DEInfringementInstance = "LG" | "OLG" | "BGH_NZB" | "BGH_REV"; +export type DENullityInstance = "BPatG" | "BGH_NULLITY"; +export type DEInstance = DEInfringementInstance | DENullityInstance; + +// UPC instance types +export type UPCInstance = "UPC_FIRST" | "UPC_APPEAL"; + +export type Instance = DEInstance | UPCInstance; + +export interface InstanceConfig { + enabled: boolean; + feeVersion: FeeScheduleVersion; + numAttorneys: number; + numPatentAttorneys: number; + oralHearing: boolean; + numClients: number; +} + +export interface UPCConfig { + enabled: boolean; + feeVersion: "pre2026" | "2026"; + isSME: boolean; + includeRevocation: boolean; +} + +export interface CalculatorInputs { + streitwert: number; + vatRate: VatRate; + jurisdiction: Jurisdiction; + // DE instances + deInstances: Record; + // UPC instances + upcInstances: Record; +} + +// Output types + +export interface AttorneyBreakdown { + verfahrensgebuehr: number; + erhoehungsgebuehr: number; + terminsgebuehr: number; + auslagenpauschale: number; + subtotalNetto: number; + vat: number; + totalBrutto: number; +} + +export interface InstanceResult { + instance: Instance; + label: string; + enabled: boolean; + gerichtskosten: number; + perAttorney: AttorneyBreakdown | null; + attorneyTotal: number; + perPatentAttorney: AttorneyBreakdown | null; + patentAttorneyTotal: number; + instanceTotal: number; +} + +export interface UPCInstanceResult { + instance: UPCInstance; + label: string; + enabled: boolean; + fixedFee: number; + valueBasedFee: number; + courtFeesTotal: number; + courtFeesSME: number; + recoverableCostsCeiling: number; + instanceTotal: number; +} + +export interface CalculatorResult { + deResults: InstanceResult[]; + upcResults: UPCInstanceResult[]; + deTotal: number; + upcTotal: number; + grandTotal: number; +} + +// Instance metadata for display and calculation +export interface InstanceMeta { + key: Instance; + label: string; + courtFeeFactor: number; + feeBasis: "GKG" | "PatKostG" | "fixed"; + fixedCourtFee?: number; + raVGFactor: number; + raTGFactor: number; + paVGFactor: number; + paTGFactor: number; + hasPatentAttorneys: boolean; +} + +export interface UPCFeeBracket { + maxValue: number | null; + fee: number; +} + +export interface UPCRecoverableCost { + maxValue: number | null; + ceiling: number; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 72fcc65..2b77c51 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -295,6 +295,44 @@ export interface ApiError { status: number; } +// Fee calculation types (Patentprozesskostenrechner) + +export interface FeeCalculateRequest { + streitwert: number; + vat_rate: number; + fee_version: string; + instances: FeeInstanceInput[]; +} + +export interface FeeInstanceInput { + instance_type: string; + enabled: boolean; + num_attorneys: number; + num_patent_attorneys: number; + oral_hearing: boolean; + num_clients: number; +} + +export interface FeeCalculateResponse { + instances: FeeInstanceOutput[]; + total: number; +} + +export interface FeeInstanceOutput { + instance_type: string; + label: string; + court_fees: number; + attorney_fees: number; + patent_attorney_fees: number; + instance_total: number; +} + +export interface FeeScheduleInfo { + version: string; + label: string; + valid_from: string; +} + export interface PaginatedResponse { data: T[]; total: number;