Compare commits

...

1 Commits

Author SHA1 Message Date
m
08399bbb0a 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.
2026-03-31 17:42:11 +02:00
11 changed files with 1629 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
"use client";
import { CostCalculator } from "@/components/costs/CostCalculator";
import { Calculator } from "lucide-react";
export default function KostenrechnerPage() {
return (
<div className="animate-fade-in space-y-4">
<div>
<div className="flex items-center gap-2">
<Calculator className="h-5 w-5 text-neutral-400" />
<h1 className="text-lg font-semibold text-neutral-900">
Patentprozesskostenrechner
</h1>
</div>
<p className="mt-0.5 text-sm text-neutral-500">
Berechnung der Verfahrenskosten für deutsche Patentverfahren und UPC
</p>
</div>
<CostCalculator />
</div>
);
}

View File

@@ -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<DEInstance, InstanceConfig> {
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<UPCInstance, UPCConfig> {
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<VatRate>(0.19);
const [jurisdiction, setJurisdiction] = useState<Jurisdiction>("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 (
<div className="grid gap-6 xl:grid-cols-[1fr_1fr]">
{/* Left: Inputs */}
<div className="space-y-6">
{/* Global inputs card */}
<div className="rounded-lg border border-neutral-200 bg-white p-4">
<h2 className="mb-4 text-sm font-semibold text-neutral-900">
Grundeinstellungen
</h2>
{/* Streitwert */}
<div className="mb-4">
<label className="mb-1.5 block text-xs font-medium text-neutral-500">
Streitwert (EUR)
</label>
<div className="flex items-center gap-3">
<input
type="text"
inputMode="numeric"
value={streitwertInput}
onChange={(e) => 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"
/>
<span className="text-xs text-neutral-400">EUR</span>
</div>
<input
type="range"
min={500}
max={30_000_000}
step={10000}
value={streitwert}
onChange={(e) => handleSliderChange(parseInt(e.target.value))}
className="mt-2 w-full accent-neutral-700"
/>
<div className="mt-1 flex justify-between text-[10px] text-neutral-400">
<span>500</span>
<span>30 Mio.</span>
</div>
{/* Presets */}
<div className="mt-2 flex flex-wrap gap-1.5">
{STREITWERT_PRESETS.map((v) => (
<button
key={v}
onClick={() => handleSliderChange(v)}
className={`rounded-full px-2.5 py-0.5 text-xs transition-colors ${
streitwert === v
? "bg-neutral-900 text-white"
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
}`}
>
{v >= 1_000_000
? `${(v / 1_000_000).toFixed(0)} Mio.`
: new Intl.NumberFormat("de-DE").format(v)}
</button>
))}
</div>
</div>
{/* VAT + Jurisdiction row */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1.5 block text-xs font-medium text-neutral-500">
Umsatzsteuer
</label>
<select
value={vatRate}
onChange={(e) => setVatRate(parseFloat(e.target.value) as VatRate)}
className="w-full rounded-md border border-neutral-200 bg-white px-2.5 py-2 text-sm text-neutral-900 focus:border-neutral-400 focus:outline-none focus:ring-1 focus:ring-neutral-400"
>
<option value={0.19}>19%</option>
<option value={0.16}>16%</option>
<option value={0}>0% (netto)</option>
</select>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-neutral-500">
Gerichtsbarkeit
</label>
<div className="flex rounded-md border border-neutral-200">
<button
onClick={() => setJurisdiction("DE")}
className={`flex-1 rounded-l-md px-3 py-2 text-sm font-medium transition-colors ${
jurisdiction === "DE"
? "bg-neutral-900 text-white"
: "bg-white text-neutral-600 hover:bg-neutral-50"
}`}
>
DE
</button>
<button
onClick={() => setJurisdiction("UPC")}
className={`flex-1 rounded-r-md px-3 py-2 text-sm font-medium transition-colors ${
jurisdiction === "UPC"
? "bg-blue-600 text-white"
: "bg-white text-neutral-600 hover:bg-neutral-50"
}`}
>
DE + UPC
</button>
</div>
</div>
</div>
</div>
{/* DE instances */}
{showDE && (
<div className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
Verletzungsverfahren
</h3>
{DE_INFRINGEMENT_INSTANCES.map((meta) => (
<InstanceCard
key={meta.key}
meta={meta}
config={deInstances[meta.key as DEInstance]}
onChange={(c) =>
setDEInstances((prev) => ({ ...prev, [meta.key as DEInstance]: c }))
}
/>
))}
<h3 className="mt-4 text-xs font-semibold uppercase tracking-wide text-neutral-500">
Nichtigkeitsverfahren
</h3>
{DE_NULLITY_INSTANCES.map((meta) => (
<InstanceCard
key={meta.key}
meta={meta}
config={deInstances[meta.key as DEInstance]}
onChange={(c) =>
setDEInstances((prev) => ({ ...prev, [meta.key as DEInstance]: c }))
}
/>
))}
</div>
)}
{/* UPC instances */}
{showUPC && (
<div className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-blue-600">
Einheitliches Patentgericht (UPC)
</h3>
<UPCCard
label="UPC (1. Instanz)"
config={upcInstances.UPC_FIRST}
onChange={(c) =>
setUPCInstances((prev) => ({ ...prev, UPC_FIRST: c }))
}
showRevocation
/>
<UPCCard
label="UPC (Berufung)"
config={upcInstances.UPC_APPEAL}
onChange={(c) =>
setUPCInstances((prev) => ({ ...prev, UPC_APPEAL: c }))
}
/>
</div>
)}
</div>
{/* Right: Results */}
<div className="space-y-4 xl:sticky xl:top-6 xl:self-start">
<CostSummary
deResults={showDE ? results.deResults : []}
upcResults={showUPC ? results.upcResults : []}
deTotal={showDE ? results.deTotal : 0}
upcTotal={showUPC ? results.upcTotal : 0}
/>
{showComparison && (
<CostComparison deTotal={results.deTotal} upcTotal={results.upcTotal} />
)}
{/* Print note */}
<p className="text-center text-[10px] text-neutral-300 print:hidden">
Alle Angaben ohne Gewähr. Berechnung basiert auf GKG/RVG/PatKostG bzw. UPC-Gebührenordnung.
</p>
</div>
</div>
);
}

View File

@@ -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 (
<div className="rounded-lg border border-neutral-200 bg-white p-4">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Kostenvergleich DE vs. UPC
</h3>
<div className="space-y-4">
{/* DE bar */}
<div>
<div className="mb-1 flex items-baseline justify-between">
<span className="text-xs font-medium text-neutral-600">{deLabel}</span>
<span className="text-sm font-semibold text-neutral-900 tabular-nums">
{formatEUR(deTotal)}
</span>
</div>
<div className="h-8 w-full overflow-hidden rounded-md bg-neutral-100">
<div
className="h-full rounded-md bg-neutral-700 transition-all duration-500"
style={{ width: `${Math.max(dePercent, 2)}%` }}
/>
</div>
</div>
{/* UPC bar */}
<div>
<div className="mb-1 flex items-baseline justify-between">
<span className="text-xs font-medium text-blue-600">{upcLabel}</span>
<span className="text-sm font-semibold text-neutral-900 tabular-nums">
{formatEUR(upcTotal)}
</span>
</div>
<div className="h-8 w-full overflow-hidden rounded-md bg-blue-50">
<div
className="h-full rounded-md bg-blue-600 transition-all duration-500"
style={{ width: `${Math.max(upcPercent, 2)}%` }}
/>
</div>
</div>
{/* Difference */}
{deTotal > 0 && upcTotal > 0 && (
<div className="flex items-center justify-between rounded-md bg-neutral-50 px-3 py-2 text-xs">
<span className="text-neutral-500">Differenz</span>
<span
className={`font-medium tabular-nums ${
diff > 0 ? "text-red-600" : diff < 0 ? "text-green-600" : "text-neutral-600"
}`}
>
{diff > 0 ? "+" : ""}
{formatEUR(diff)} ({diff > 0 ? "+" : ""}
{diffPercent}%)
</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -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 (
<div className="rounded-lg border border-neutral-200 bg-white p-4">
<h4 className="mb-3 text-sm font-medium text-neutral-900">{result.label}</h4>
<div className="space-y-2 text-sm">
{/* Court fees */}
<div className="flex justify-between">
<span className="text-neutral-600">Gerichtskosten</span>
<span className="font-medium text-neutral-900 tabular-nums">
{formatEUR(result.gerichtskosten)}
</span>
</div>
{/* Attorney fees */}
{result.perAttorney && (
<div>
<div className="flex justify-between">
<span className="text-neutral-600">
Rechtsanwaltskosten
{result.attorneyTotal !== result.perAttorney.totalBrutto && (
<span className="text-neutral-400">
{" "}
({Math.round(result.attorneyTotal / result.perAttorney.totalBrutto)}x)
</span>
)}
</span>
<span className="font-medium text-neutral-900 tabular-nums">
{formatEUR(result.attorneyTotal)}
</span>
</div>
{/* Detail breakdown */}
<div className="ml-4 mt-1 space-y-0.5 text-xs text-neutral-400">
<div className="flex justify-between">
<span>Verfahrensgebühr</span>
<span className="tabular-nums">{formatEUR(result.perAttorney.verfahrensgebuehr)}</span>
</div>
{result.perAttorney.erhoehungsgebuehr > 0 && (
<div className="flex justify-between">
<span>Erhöhungsgebühr</span>
<span className="tabular-nums">{formatEUR(result.perAttorney.erhoehungsgebuehr)}</span>
</div>
)}
{result.perAttorney.terminsgebuehr > 0 && (
<div className="flex justify-between">
<span>Terminsgebühr</span>
<span className="tabular-nums">{formatEUR(result.perAttorney.terminsgebuehr)}</span>
</div>
)}
<div className="flex justify-between">
<span>Auslagenpauschale</span>
<span className="tabular-nums">{formatEUR(result.perAttorney.auslagenpauschale)}</span>
</div>
{result.perAttorney.vat > 0 && (
<div className="flex justify-between">
<span>USt.</span>
<span className="tabular-nums">{formatEUR(result.perAttorney.vat)}</span>
</div>
)}
</div>
</div>
)}
{/* Patent attorney fees */}
{result.perPatentAttorney && (
<div>
<div className="flex justify-between">
<span className="text-neutral-600">
Patentanwaltskosten
{result.patentAttorneyTotal !== result.perPatentAttorney.totalBrutto && (
<span className="text-neutral-400">
{" "}
({Math.round(result.patentAttorneyTotal / result.perPatentAttorney.totalBrutto)}x)
</span>
)}
</span>
<span className="font-medium text-neutral-900 tabular-nums">
{formatEUR(result.patentAttorneyTotal)}
</span>
</div>
</div>
)}
{/* Instance total */}
<div className="flex justify-between border-t border-neutral-100 pt-2">
<span className="font-medium text-neutral-900">Zwischensumme</span>
<span className="font-semibold text-neutral-900 tabular-nums">
{formatEUR(result.instanceTotal)}
</span>
</div>
</div>
</div>
);
}
function UPCInstanceBreakdown({ result }: { result: UPCInstanceResult }) {
if (!result.enabled) return null;
return (
<div className="rounded-lg border border-blue-200 bg-blue-50/30 p-4">
<h4 className="mb-3 text-sm font-medium text-neutral-900">{result.label}</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-neutral-600">Festgebühr</span>
<span className="font-medium text-neutral-900 tabular-nums">
{formatEUR(result.fixedFee)}
</span>
</div>
{result.valueBasedFee > 0 && (
<div className="flex justify-between">
<span className="text-neutral-600">Streitwertabhängige Gebühr</span>
<span className="font-medium text-neutral-900 tabular-nums">
{formatEUR(result.valueBasedFee)}
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-neutral-600">Gerichtskosten gesamt</span>
<span className="font-medium text-neutral-900 tabular-nums">
{formatEUR(result.courtFeesTotal)}
</span>
</div>
{result.courtFeesSME !== result.courtFeesTotal && (
<div className="flex justify-between text-blue-700">
<span>Gerichtskosten (KMU)</span>
<span className="font-medium tabular-nums">{formatEUR(result.courtFeesSME)}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-neutral-600">Erstattungsfähige Kosten (Deckel)</span>
<span className="font-medium text-neutral-900 tabular-nums">
{formatEUR(result.recoverableCostsCeiling)}
</span>
</div>
<div className="flex justify-between border-t border-blue-100 pt-2">
<span className="font-medium text-neutral-900">Gesamtkostenrisiko</span>
<span className="font-semibold text-neutral-900 tabular-nums">
{formatEUR(result.instanceTotal)}
</span>
</div>
</div>
</div>
);
}
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 (
<div className="flex items-center justify-center rounded-lg border border-dashed border-neutral-200 bg-neutral-50 p-8">
<p className="text-sm text-neutral-400">
Mindestens eine Instanz aktivieren, um Kosten zu berechnen.
</p>
</div>
);
}
return (
<div className="space-y-4">
{/* DE results */}
{hasDE && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-neutral-500 uppercase tracking-wide">
Deutsche Gerichte
</h3>
{deResults.map((r) => (
<DEInstanceBreakdown key={r.instance} result={r} />
))}
{deResults.filter((r) => r.enabled).length > 1 && (
<div className="flex justify-between rounded-lg bg-neutral-900 px-4 py-3 text-white">
<span className="text-sm font-medium">Gesamtkosten DE</span>
<span className="text-sm font-semibold tabular-nums">{formatEUR(deTotal)}</span>
</div>
)}
</div>
)}
{/* UPC results */}
{hasUPC && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-blue-600 uppercase tracking-wide">
Einheitliches Patentgericht (UPC)
</h3>
{upcResults.map((r) => (
<UPCInstanceBreakdown key={r.instance} result={r} />
))}
{upcResults.filter((r) => r.enabled).length > 1 && (
<div className="flex justify-between rounded-lg bg-blue-900 px-4 py-3 text-white">
<span className="text-sm font-medium">Gesamtkosten UPC</span>
<span className="text-sm font-semibold tabular-nums">{formatEUR(upcTotal)}</span>
</div>
)}
</div>
)}
{/* Grand total when both */}
{hasDE && hasUPC && (
<div className="mt-2 flex justify-between rounded-lg border-2 border-neutral-900 px-4 py-3">
<span className="text-sm font-semibold text-neutral-900">Gesamtkosten</span>
<span className="text-sm font-bold text-neutral-900 tabular-nums">
{formatEUR(deTotal + upcTotal)}
</span>
</div>
)}
</div>
);
}

View File

@@ -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<InstanceConfig>) {
onChange({ ...config, ...patch });
}
return (
<div
className={`rounded-lg border transition-colors ${
config.enabled
? "border-neutral-200 bg-white"
: "border-neutral-100 bg-neutral-50"
}`}
>
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => {
update({ enabled: e.target.checked });
if (e.target.checked) setExpanded(true);
}}
className="h-4 w-4 rounded border-neutral-300 text-neutral-900 focus:ring-neutral-500"
/>
<span
className={`text-sm font-medium ${
config.enabled ? "text-neutral-900" : "text-neutral-400"
}`}
>
{meta.label}
</span>
</label>
{config.enabled && (
<button
onClick={() => setExpanded(!expanded)}
className="ml-auto rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
>
{expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
)}
</div>
{/* Settings */}
{config.enabled && expanded && (
<div className="border-t border-neutral-100 px-4 pb-4 pt-3">
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3">
{/* Fee version */}
<div>
<label className="mb-1 block text-xs text-neutral-500">
Gebührentabelle
</label>
<select
value={config.feeVersion}
onChange={(e) =>
update({ feeVersion: e.target.value as FeeScheduleVersion })
}
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"
>
{VERSION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* Number of attorneys */}
<div>
<label className="mb-1 block text-xs text-neutral-500">
Rechtsanwälte
</label>
<input
type="number"
min={0}
max={10}
value={config.numAttorneys}
onChange={(e) =>
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"
/>
</div>
{/* Number of patent attorneys */}
{meta.hasPatentAttorneys && (
<div>
<label className="mb-1 block text-xs text-neutral-500">
Patentanwälte
</label>
<input
type="number"
min={0}
max={10}
value={config.numPatentAttorneys}
onChange={(e) =>
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"
/>
</div>
)}
{/* Oral hearing */}
<div className="flex items-end">
<label className="flex items-center gap-2 pb-1.5">
<input
type="checkbox"
checked={config.oralHearing}
onChange={(e) => update({ oralHearing: e.target.checked })}
className="h-4 w-4 rounded border-neutral-300 text-neutral-900 focus:ring-neutral-500"
/>
<span className="text-sm text-neutral-700">
Mündliche Verhandlung
</span>
</label>
</div>
{/* Number of clients */}
<div>
<label className="mb-1 block text-xs text-neutral-500">
Mandanten
</label>
<input
type="number"
min={1}
max={20}
value={config.numClients}
onChange={(e) =>
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"
/>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -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<UPCConfig>) {
onChange({ ...config, ...patch });
}
return (
<div
className={`rounded-lg border transition-colors ${
config.enabled
? "border-blue-200 bg-blue-50/30"
: "border-neutral-100 bg-neutral-50"
}`}
>
<div className="flex items-center gap-3 px-4 py-3">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => {
update({ enabled: e.target.checked });
if (e.target.checked) setExpanded(true);
}}
className="h-4 w-4 rounded border-neutral-300 text-blue-600 focus:ring-blue-500"
/>
<span
className={`text-sm font-medium ${
config.enabled ? "text-neutral-900" : "text-neutral-400"
}`}
>
{label}
</span>
</label>
{config.enabled && (
<button
onClick={() => setExpanded(!expanded)}
className="ml-auto rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
>
{expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
)}
</div>
{config.enabled && expanded && (
<div className="border-t border-blue-100 px-4 pb-4 pt-3">
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3">
<div>
<label className="mb-1 block text-xs text-neutral-500">
Gebührenordnung
</label>
<select
value={config.feeVersion}
onChange={(e) =>
update({ feeVersion: e.target.value as "pre2026" | "2026" })
}
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"
>
<option value="2026">UPC (ab 2026)</option>
<option value="pre2026">UPC (vor 2026)</option>
</select>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 pb-1.5">
<input
type="checkbox"
checked={config.isSME}
onChange={(e) => update({ isSME: e.target.checked })}
className="h-4 w-4 rounded border-neutral-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-neutral-700">KMU-Ermäßigung</span>
</label>
</div>
{showRevocation && (
<div className="flex items-end">
<label className="flex items-center gap-2 pb-1.5">
<input
type="checkbox"
checked={config.includeRevocation}
onChange={(e) =>
update({ includeRevocation: e.target.checked })
}
className="h-4 w-4 rounded border-neutral-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-neutral-700">
Nichtigkeitswiderklage
</span>
</label>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -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" },
];

View File

@@ -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);
}

View File

@@ -0,0 +1,239 @@
import type {
FeeScheduleVersion,
FeeScheduleEntry,
InstanceMeta,
UPCFeeBracket,
UPCRecoverableCost,
} from "./types";
// Fee schedules: [upperBound, stepSize, gkgIncrement, rvgIncrement]
export const FEE_SCHEDULES: Record<FeeScheduleVersion, FeeScheduleEntry> = {
"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;

View File

@@ -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<DEInstance, InstanceConfig>;
// UPC instances
upcInstances: Record<UPCInstance, UPCConfig>;
}
// 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;
}

View File

@@ -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<T> {
data: T[];
total: number;