Compare commits

...

6 Commits

Author SHA1 Message Date
m
63b43ff7c8 feat: Patentprozesskostenrechner — frontend UI at /kosten/rechner 2026-03-31 17:44:59 +02:00
m
f43f6e3eea feat: Patentprozesskostenrechner — backend fee engine + API 2026-03-31 17:44:44 +02:00
m
850f3a62c8 feat: add Patentprozesskostenrechner fee calculation engine + API
Pure Go implementation of patent litigation cost calculator with:
- Step-based GKG/RVG fee accumulator across 4 historical schedules (2005/2013/2021/2025 + Aktuell alias)
- Instance multiplier tables for 8 court types (LG, OLG, BGH NZB/Rev, BPatG, BGH Null, DPMA, BPatG Canc)
- Full attorney fee calculation (VG, TG, Erhöhungsgebühr Nr. 1008 VV RVG, Auslagenpauschale)
- Prozesskostensicherheit computation
- UPC fee data (pre-2026 and 2026 schedules with value-based brackets, recoverable costs ceilings)
- Public API: POST /api/fees/calculate, GET /api/fees/schedules (no auth required)
- 22 unit tests covering all calculation paths

Fixes 3 Excel bugs:
- Bug 1: Prozesskostensicherheit VAT formula (subtract → add)
- Bug 2: Security for costs uses GKG base for court fee, not RVG
- Bug 3: Expert fees included in BPatG instance total
2026-03-31 17:43:17 +02:00
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
m
d4092acc33 docs: Patentprozesskostenrechner implementation plan 2026-03-31 17:31:37 +02:00
m
7c70649494 docs: add Patentprozesskostenrechner implementation plan
Comprehensive analysis of the Excel-based patent litigation cost calculator
with implementation plan for the web version:

- Fee calculation logic (GKG/RVG step-based accumulator, all multipliers)
- Exact fee schedule data for all 5 versions (extracted from Excel)
- UPC fee structure research (fixed fees, value-based brackets, recoverable costs)
- Architecture: new page at /kosten/rechner within KanzlAI-mGMT (pure frontend)
- Complete input/output specifications
- 3 bugs to fix from the Excel (VAT formula, wrong fee type, missing expert fees)
- Side-by-side DE vs UPC cost comparison data
2026-03-31 17:28:39 +02:00
19 changed files with 3781 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
package handlers
import (
"encoding/json"
"net/http"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
// FeeCalculatorHandler handles fee calculation API endpoints.
type FeeCalculatorHandler struct {
calc *services.FeeCalculator
}
func NewFeeCalculatorHandler(calc *services.FeeCalculator) *FeeCalculatorHandler {
return &FeeCalculatorHandler{calc: calc}
}
// Calculate handles POST /api/fees/calculate.
func (h *FeeCalculatorHandler) Calculate(w http.ResponseWriter, r *http.Request) {
var req models.FeeCalculateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Streitwert <= 0 {
writeError(w, http.StatusBadRequest, "streitwert must be positive")
return
}
if req.VATRate < 0 || req.VATRate > 1 {
writeError(w, http.StatusBadRequest, "vat_rate must be between 0 and 1")
return
}
if len(req.Instances) == 0 {
writeError(w, http.StatusBadRequest, "at least one instance is required")
return
}
resp, err := h.calc.CalculateFullLitigation(req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, resp)
}
// Schedules handles GET /api/fees/schedules.
func (h *FeeCalculatorHandler) Schedules(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.calc.GetSchedules())
}

View File

@@ -0,0 +1,125 @@
package models
// FeeScheduleVersion identifies a fee schedule version.
type FeeScheduleVersion string
const (
FeeVersion2005 FeeScheduleVersion = "2005"
FeeVersion2013 FeeScheduleVersion = "2013"
FeeVersion2021 FeeScheduleVersion = "2021"
FeeVersion2025 FeeScheduleVersion = "2025"
FeeVersionAktuell FeeScheduleVersion = "Aktuell"
)
// InstanceType identifies a court instance.
type InstanceType string
const (
InstanceLG InstanceType = "LG"
InstanceOLG InstanceType = "OLG"
InstanceBGHNZB InstanceType = "BGH_NZB"
InstanceBGHRev InstanceType = "BGH_Rev"
InstanceBPatG InstanceType = "BPatG"
InstanceBGHNull InstanceType = "BGH_Null"
InstanceDPMA InstanceType = "DPMA"
InstanceBPatGCanc InstanceType = "BPatG_Canc"
)
// ProceedingPath identifies the type of patent litigation proceeding.
type ProceedingPath string
const (
PathInfringement ProceedingPath = "infringement"
PathNullity ProceedingPath = "nullity"
PathCancellation ProceedingPath = "cancellation"
)
// --- Request ---
// FeeCalculateRequest is the request body for POST /api/fees/calculate.
type FeeCalculateRequest struct {
Streitwert float64 `json:"streitwert"`
VATRate float64 `json:"vat_rate"`
ProceedingPath ProceedingPath `json:"proceeding_path"`
Instances []InstanceInput `json:"instances"`
IncludeSecurityCosts bool `json:"include_security_costs"`
}
// InstanceInput configures one court instance in the calculation request.
type InstanceInput struct {
Type InstanceType `json:"type"`
Enabled bool `json:"enabled"`
FeeVersion FeeScheduleVersion `json:"fee_version"`
NumAttorneys int `json:"num_attorneys"`
NumPatentAttorneys int `json:"num_patent_attorneys"`
NumClients int `json:"num_clients"`
OralHearing bool `json:"oral_hearing"`
ExpertFees float64 `json:"expert_fees"`
}
// --- Response ---
// FeeCalculateResponse is the response for POST /api/fees/calculate.
type FeeCalculateResponse struct {
Instances []InstanceResult `json:"instances"`
Totals []FeeTotal `json:"totals"`
SecurityForCosts *SecurityForCosts `json:"security_for_costs,omitempty"`
}
// InstanceResult contains the cost breakdown for one court instance.
type InstanceResult struct {
Type InstanceType `json:"type"`
Label string `json:"label"`
CourtFeeBase float64 `json:"court_fee_base"`
CourtFeeMultiplier float64 `json:"court_fee_multiplier"`
CourtFeeSource string `json:"court_fee_source"`
CourtFee float64 `json:"court_fee"`
ExpertFees float64 `json:"expert_fees"`
CourtSubtotal float64 `json:"court_subtotal"`
AttorneyBreakdown *AttorneyBreakdown `json:"attorney_breakdown,omitempty"`
PatentAttorneyBreakdown *AttorneyBreakdown `json:"patent_attorney_breakdown,omitempty"`
AttorneySubtotal float64 `json:"attorney_subtotal"`
PatentAttorneySubtotal float64 `json:"patent_attorney_subtotal"`
InstanceTotal float64 `json:"instance_total"`
}
// AttorneyBreakdown details the fee computation for one attorney type.
type AttorneyBreakdown struct {
BaseFee float64 `json:"base_fee"`
VGFactor float64 `json:"vg_factor"`
VGFee float64 `json:"vg_fee"`
IncreaseFee float64 `json:"increase_fee"`
TGFactor float64 `json:"tg_factor"`
TGFee float64 `json:"tg_fee"`
Pauschale float64 `json:"pauschale"`
SubtotalNet float64 `json:"subtotal_net"`
VAT float64 `json:"vat"`
SubtotalGross float64 `json:"subtotal_gross"`
Count int `json:"count"`
TotalGross float64 `json:"total_gross"`
}
// FeeTotal is a labeled total amount.
type FeeTotal struct {
Label string `json:"label"`
Total float64 `json:"total"`
}
// SecurityForCosts is the Prozesskostensicherheit calculation result.
type SecurityForCosts struct {
Instance1 float64 `json:"instance_1"`
Instance2 float64 `json:"instance_2"`
NZB float64 `json:"nzb"`
SubtotalNet float64 `json:"subtotal_net"`
VAT float64 `json:"vat"`
TotalGross float64 `json:"total_gross"`
}
// FeeScheduleInfo describes a fee schedule version for the schedules endpoint.
type FeeScheduleInfo struct {
Key string `json:"key"`
Label string `json:"label"`
ValidFrom string `json:"valid_from"`
IsAlias bool `json:"is_alias,omitempty"`
AliasOf string `json:"alias_of,omitempty"`
}

View File

@@ -82,6 +82,12 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
billingH := handlers.NewBillingRateHandler(billingRateSvc) billingH := handlers.NewBillingRateHandler(billingRateSvc)
templateH := handlers.NewTemplateHandler(templateSvc, caseSvc, partySvc, deadlineSvc, tenantSvc) templateH := handlers.NewTemplateHandler(templateSvc, caseSvc, partySvc, deadlineSvc, tenantSvc)
// Fee calculator (public — no auth required, pure computation)
feeCalc := services.NewFeeCalculator()
feeCalcH := handlers.NewFeeCalculatorHandler(feeCalc)
mux.HandleFunc("POST /api/fees/calculate", feeCalcH.Calculate)
mux.HandleFunc("GET /api/fees/schedules", feeCalcH.Schedules)
// Public routes // Public routes
mux.HandleFunc("GET /health", handleHealth(db)) mux.HandleFunc("GET /health", handleHealth(db))

View File

@@ -0,0 +1,393 @@
package services
import (
"fmt"
"math"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
// FeeCalculator computes patent litigation costs based on GKG/RVG fee schedules.
type FeeCalculator struct{}
func NewFeeCalculator() *FeeCalculator {
return &FeeCalculator{}
}
// resolveSchedule returns the actual schedule data, resolving aliases.
func resolveSchedule(version string) (*feeScheduleData, error) {
sched, ok := feeSchedules[version]
if !ok {
return nil, fmt.Errorf("unknown fee schedule: %s", version)
}
if sched.AliasOf != "" {
target, ok := feeSchedules[sched.AliasOf]
if !ok {
return nil, fmt.Errorf("alias target not found: %s", sched.AliasOf)
}
return target, nil
}
return sched, nil
}
// CalculateBaseFee computes the base 1.0x fee using the step-based accumulator.
// feeType must be "GKG" or "RVG".
//
// Algorithm: The first bracket defines the minimum fee (one step). Each subsequent
// bracket accumulates ceil(portion / stepSize) * increment for the portion of the
// Streitwert that falls within that bracket's range.
func (fc *FeeCalculator) CalculateBaseFee(streitwert float64, version string, feeType string) (float64, error) {
sched, err := resolveSchedule(version)
if err != nil {
return 0, err
}
if streitwert <= 0 {
return 0, nil
}
brackets := sched.Brackets
fee := 0.0
prevUpper := 0.0
for i, b := range brackets {
increment := b.GKGIncrement
if feeType == "RVG" {
increment = b.RVGIncrement
}
if i == 0 {
// First bracket: minimum fee = one increment
fee = increment
prevUpper = b.UpperBound
continue
}
if streitwert <= prevUpper {
break
}
portion := math.Min(streitwert, b.UpperBound) - prevUpper
if portion <= 0 {
break
}
steps := math.Ceil(portion / b.StepSize)
fee += steps * increment
prevUpper = b.UpperBound
}
return fee, nil
}
// CalculateCourtFees computes court fees for a given instance type.
// Returns the base fee, multiplier, computed court fee, and the fee source.
func (fc *FeeCalculator) CalculateCourtFees(streitwert float64, instanceType string, version string) (baseFee, multiplier, courtFee float64, source string, err error) {
cfg, ok := instanceConfigs[instanceType]
if !ok {
return 0, 0, 0, "", fmt.Errorf("unknown instance type: %s", instanceType)
}
source = cfg.CourtSource
if cfg.CourtSource == "fixed" {
return 0, 0, cfg.FixedCourtFee, source, nil
}
// Both GKG and PatKostG use the same step-based GKG bracket lookup
baseFee, err = fc.CalculateBaseFee(streitwert, version, "GKG")
if err != nil {
return 0, 0, 0, "", err
}
multiplier = cfg.CourtFactor
courtFee = baseFee * multiplier
return baseFee, multiplier, courtFee, source, nil
}
// CalculateAttorneyFees computes the fees for one attorney type at one instance.
// Returns nil if numAttorneys <= 0.
func (fc *FeeCalculator) CalculateAttorneyFees(
streitwert float64,
version string,
vgFactor, tgFactor float64,
numAttorneys, numClients int,
oralHearing bool,
vatRate float64,
) (*models.AttorneyBreakdown, error) {
if numAttorneys <= 0 {
return nil, nil
}
baseFee, err := fc.CalculateBaseFee(streitwert, version, "RVG")
if err != nil {
return nil, err
}
vgFee := vgFactor * baseFee
// Increase fee (Nr. 1008 VV RVG) for multiple clients
increaseFee := 0.0
if numClients > 1 {
factor := float64(numClients-1) * erhoehungsfaktor
if factor > erhoehungsfaktorMax {
factor = erhoehungsfaktorMax
}
increaseFee = factor * baseFee
}
tgFee := 0.0
if oralHearing {
tgFee = tgFactor * baseFee
}
subtotalNet := vgFee + increaseFee + tgFee + auslagenpauschale
vat := subtotalNet * vatRate
subtotalGross := subtotalNet + vat
totalGross := subtotalGross * float64(numAttorneys)
return &models.AttorneyBreakdown{
BaseFee: baseFee,
VGFactor: vgFactor,
VGFee: vgFee,
IncreaseFee: increaseFee,
TGFactor: tgFactor,
TGFee: tgFee,
Pauschale: auslagenpauschale,
SubtotalNet: subtotalNet,
VAT: vat,
SubtotalGross: subtotalGross,
Count: numAttorneys,
TotalGross: totalGross,
}, nil
}
// CalculateInstanceTotal computes the full cost for one court instance.
// Bug 3 fix: expert fees are included in the court subtotal (not silently dropped).
func (fc *FeeCalculator) CalculateInstanceTotal(streitwert float64, inst models.InstanceInput, vatRate float64) (*models.InstanceResult, error) {
cfg, ok := instanceConfigs[string(inst.Type)]
if !ok {
return nil, fmt.Errorf("unknown instance type: %s", inst.Type)
}
version := string(inst.FeeVersion)
if version == "" {
version = "Aktuell"
}
baseFee, multiplier, courtFee, source, err := fc.CalculateCourtFees(streitwert, string(inst.Type), version)
if err != nil {
return nil, fmt.Errorf("court fees: %w", err)
}
// Bug 3 fix: include expert fees in court subtotal
courtSubtotal := courtFee + inst.ExpertFees
// Attorney (Rechtsanwalt) fees
raBreakdown, err := fc.CalculateAttorneyFees(
streitwert, version,
cfg.RAVGFactor, cfg.RATGFactor,
inst.NumAttorneys, inst.NumClients,
inst.OralHearing, vatRate,
)
if err != nil {
return nil, fmt.Errorf("attorney fees: %w", err)
}
// Patent attorney (Patentanwalt) fees
var paBreakdown *models.AttorneyBreakdown
if cfg.HasPA && inst.NumPatentAttorneys > 0 {
paBreakdown, err = fc.CalculateAttorneyFees(
streitwert, version,
cfg.PAVGFactor, cfg.PATGFactor,
inst.NumPatentAttorneys, inst.NumClients,
inst.OralHearing, vatRate,
)
if err != nil {
return nil, fmt.Errorf("patent attorney fees: %w", err)
}
}
attorneyTotal := 0.0
if raBreakdown != nil {
attorneyTotal = raBreakdown.TotalGross
}
paTotal := 0.0
if paBreakdown != nil {
paTotal = paBreakdown.TotalGross
}
return &models.InstanceResult{
Type: inst.Type,
Label: cfg.Label,
CourtFeeBase: baseFee,
CourtFeeMultiplier: multiplier,
CourtFeeSource: source,
CourtFee: courtFee,
ExpertFees: inst.ExpertFees,
CourtSubtotal: courtSubtotal,
AttorneyBreakdown: raBreakdown,
PatentAttorneyBreakdown: paBreakdown,
AttorneySubtotal: attorneyTotal,
PatentAttorneySubtotal: paTotal,
InstanceTotal: courtSubtotal + attorneyTotal + paTotal,
}, nil
}
// CalculateSecurityForCosts computes the Prozesskostensicherheit.
//
// Bug 1 fix: uses (1 + VAT) for the total, not (1 - VAT) as in the Excel.
// Bug 2 fix: uses GKG base fee for the court fee component, not RVG.
func (fc *FeeCalculator) CalculateSecurityForCosts(streitwert float64, version string, numClients int, vatRate float64) (*models.SecurityForCosts, error) {
rvgBase, err := fc.CalculateBaseFee(streitwert, version, "RVG")
if err != nil {
return nil, err
}
// Bug 2 fix: use GKG base for court fees, not RVG
gkgBase, err := fc.CalculateBaseFee(streitwert, version, "GKG")
if err != nil {
return nil, err
}
// Increase fee (Nr. 1008 VV RVG) for multiple clients
increaseFee := 0.0
if numClients > 1 {
factor := float64(numClients-1) * erhoehungsfaktor
if factor > erhoehungsfaktorMax {
factor = erhoehungsfaktorMax
}
increaseFee = factor * rvgBase
}
// 1. Instanz: 2.5x RA + increase + 2.5x PA + increase + EUR 5,000
inst1 := 2.5*rvgBase + increaseFee + 2.5*rvgBase + increaseFee + 5000
// 2. Instanz: 2.8x RA + increase + 2.8x PA + increase + 4.0x GKG court + EUR 5,000
inst2 := 2.8*rvgBase + increaseFee + 2.8*rvgBase + increaseFee + 4.0*gkgBase + 5000
// NZB: 2.3x RA + increase + 2.3x PA + increase
nzb := 2.3*rvgBase + increaseFee + 2.3*rvgBase + increaseFee
subtotalNet := inst1 + inst2 + nzb
// Bug 1 fix: add VAT, don't subtract
vat := subtotalNet * vatRate
totalGross := subtotalNet + vat
return &models.SecurityForCosts{
Instance1: inst1,
Instance2: inst2,
NZB: nzb,
SubtotalNet: subtotalNet,
VAT: vat,
TotalGross: totalGross,
}, nil
}
// CalculateFullLitigation computes costs for all enabled instances in a proceeding path.
func (fc *FeeCalculator) CalculateFullLitigation(req models.FeeCalculateRequest) (*models.FeeCalculateResponse, error) {
if req.Streitwert <= 0 {
return nil, fmt.Errorf("streitwert must be positive")
}
resp := &models.FeeCalculateResponse{}
grandTotal := 0.0
for _, inst := range req.Instances {
if !inst.Enabled {
continue
}
result, err := fc.CalculateInstanceTotal(req.Streitwert, inst, req.VATRate)
if err != nil {
return nil, fmt.Errorf("instance %s: %w", inst.Type, err)
}
resp.Instances = append(resp.Instances, *result)
grandTotal += result.InstanceTotal
}
// Build totals based on proceeding path
switch req.ProceedingPath {
case models.PathInfringement:
resp.Totals = fc.infringementTotals(resp.Instances)
case models.PathNullity:
resp.Totals = []models.FeeTotal{{Label: "Gesamtkosten Nichtigkeitsverfahren", Total: grandTotal}}
case models.PathCancellation:
resp.Totals = []models.FeeTotal{{Label: "Gesamtkosten Löschungsverfahren", Total: grandTotal}}
default:
resp.Totals = []models.FeeTotal{{Label: "Gesamtkosten", Total: grandTotal}}
}
// Security for costs (only for infringement proceedings)
if req.IncludeSecurityCosts && req.ProceedingPath == models.PathInfringement {
version := "Aktuell"
numClients := 1
for _, inst := range req.Instances {
if inst.Enabled {
version = string(inst.FeeVersion)
if version == "" {
version = "Aktuell"
}
numClients = inst.NumClients
break
}
}
sec, err := fc.CalculateSecurityForCosts(req.Streitwert, version, numClients, req.VATRate)
if err != nil {
return nil, fmt.Errorf("security for costs: %w", err)
}
resp.SecurityForCosts = sec
}
return resp, nil
}
// infringementTotals builds summary totals for infringement proceedings.
func (fc *FeeCalculator) infringementTotals(instances []models.InstanceResult) []models.FeeTotal {
byType := make(map[models.InstanceType]float64)
for _, inst := range instances {
byType[inst.Type] = inst.InstanceTotal
}
var totals []models.FeeTotal
// "Gesamtkosten bei Nichtzulassung" = LG + OLG + BGH NZB
nzb := byType[models.InstanceLG] + byType[models.InstanceOLG] + byType[models.InstanceBGHNZB]
if nzb > 0 {
totals = append(totals, models.FeeTotal{Label: "Gesamtkosten bei Nichtzulassung", Total: nzb})
}
// "Gesamtkosten bei Revision" = LG + OLG + BGH Rev
rev := byType[models.InstanceLG] + byType[models.InstanceOLG] + byType[models.InstanceBGHRev]
if rev > 0 {
totals = append(totals, models.FeeTotal{Label: "Gesamtkosten bei Revision", Total: rev})
}
if len(totals) == 0 {
total := 0.0
for _, v := range byType {
total += v
}
totals = append(totals, models.FeeTotal{Label: "Gesamtkosten Verletzungsverfahren", Total: total})
}
return totals
}
// GetSchedules returns information about all available fee schedules.
func (fc *FeeCalculator) GetSchedules() []models.FeeScheduleInfo {
order := []string{"2005", "2013", "2021", "2025", "Aktuell"}
result := make([]models.FeeScheduleInfo, 0, len(order))
for _, key := range order {
sched := feeSchedules[key]
info := models.FeeScheduleInfo{
Key: key,
Label: sched.Label,
ValidFrom: sched.ValidFrom,
}
if sched.AliasOf != "" {
info.IsAlias = true
info.AliasOf = sched.AliasOf
}
result = append(result, info)
}
return result
}

View File

@@ -0,0 +1,421 @@
package services
import (
"math"
"testing"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
func TestCalculateBaseFee_GKG_2025(t *testing.T) {
fc := NewFeeCalculator()
tests := []struct {
name string
streitwert float64
want float64
}{
{"minimum (500 EUR)", 500, 40},
{"1000 EUR", 1000, 61}, // 40 + ceil(500/500)*21 = 40+21
{"2000 EUR", 2000, 103}, // 40 + ceil(1500/500)*21 = 40+63
{"500k EUR", 500000, 4138}, // verified against GKG Anlage 2
{"1M EUR", 1000000, 6238}, // 4138 + ceil(500k/50k)*210 = 4138+2100
{"3M EUR", 3000000, 14638}, // 4138 + ceil(2.5M/50k)*210 = 4138+10500
{"5M EUR", 5000000, 23038}, // 4138 + ceil(4.5M/50k)*210 = 4138+18900
{"10M EUR", 10000000, 44038}, // 4138 + ceil(9.5M/50k)*210 = 4138+39900
{"30M EUR", 30000000, 128038}, // 4138 + ceil(29.5M/50k)*210 = 4138+123900
{"50M EUR", 50000000, 212038}, // 4138 + ceil(49.5M/50k)*210 = 4138+207900
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := fc.CalculateBaseFee(tt.streitwert, "2025", "GKG")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if math.Abs(got-tt.want) > 0.01 {
t.Errorf("CalculateBaseFee(%v, 2025, GKG) = %v, want %v", tt.streitwert, got, tt.want)
}
})
}
}
func TestCalculateBaseFee_RVG_2025(t *testing.T) {
fc := NewFeeCalculator()
tests := []struct {
name string
streitwert float64
want float64
}{
{"minimum (500 EUR)", 500, 51.5},
{"1000 EUR", 1000, 93}, // 51.5 + ceil(500/500)*41.5 = 51.5+41.5
{"2000 EUR", 2000, 176}, // 51.5 + ceil(1500/500)*41.5 = 51.5+124.5
{"500k EUR", 500000, 3752}, // computed via brackets
{"1M EUR", 1000000, 5502}, // 3752 + ceil(500k/50k)*175 = 3752+1750
{"3M EUR", 3000000, 12502}, // 3752 + ceil(2.5M/50k)*175 = 3752+8750
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := fc.CalculateBaseFee(tt.streitwert, "2025", "RVG")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if math.Abs(got-tt.want) > 0.01 {
t.Errorf("CalculateBaseFee(%v, 2025, RVG) = %v, want %v", tt.streitwert, got, tt.want)
}
})
}
}
func TestCalculateBaseFee_GKG_2013(t *testing.T) {
fc := NewFeeCalculator()
tests := []struct {
name string
streitwert float64
want float64
}{
{"minimum (500 EUR)", 500, 35},
{"1000 EUR", 1000, 53}, // 35 + ceil(500/500)*18 = 35+18
{"1500 EUR", 1500, 71}, // 35 + ceil(1000/500)*18 = 35+36
{"2000 EUR", 2000, 89}, // 35 + ceil(1500/500)*18 = 35+54
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := fc.CalculateBaseFee(tt.streitwert, "2013", "GKG")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if math.Abs(got-tt.want) > 0.01 {
t.Errorf("CalculateBaseFee(%v, 2013, GKG) = %v, want %v", tt.streitwert, got, tt.want)
}
})
}
}
func TestCalculateBaseFee_Aktuell_IsAlias(t *testing.T) {
fc := NewFeeCalculator()
aktuell, err := fc.CalculateBaseFee(1000000, "Aktuell", "GKG")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
v2025, err := fc.CalculateBaseFee(1000000, "2025", "GKG")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if aktuell != v2025 {
t.Errorf("Aktuell (%v) should equal 2025 (%v)", aktuell, v2025)
}
}
func TestCalculateBaseFee_InvalidSchedule(t *testing.T) {
fc := NewFeeCalculator()
_, err := fc.CalculateBaseFee(1000, "1999", "GKG")
if err == nil {
t.Error("expected error for unknown schedule")
}
}
func TestCalculateCourtFees(t *testing.T) {
fc := NewFeeCalculator()
tests := []struct {
name string
streitwert float64
instanceType string
version string
wantCourtFee float64
wantSource string
}{
{"LG 1M (3.0x GKG)", 1000000, "LG", "2025", 18714, "GKG"},
{"LG 3M (3.0x GKG)", 3000000, "LG", "2025", 43914, "GKG"},
{"OLG 3M (4.0x GKG)", 3000000, "OLG", "2025", 58552, "GKG"},
{"BPatG 1M (4.5x PatKostG)", 1000000, "BPatG", "2025", 28071, "PatKostG"},
{"DPMA (fixed 300)", 1000000, "DPMA", "2025", 300, "fixed"},
{"BPatG_Canc (fixed 500)", 1000000, "BPatG_Canc", "2025", 500, "fixed"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, courtFee, source, err := fc.CalculateCourtFees(tt.streitwert, tt.instanceType, tt.version)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if math.Abs(courtFee-tt.wantCourtFee) > 0.01 {
t.Errorf("court fee = %v, want %v", courtFee, tt.wantCourtFee)
}
if source != tt.wantSource {
t.Errorf("source = %v, want %v", source, tt.wantSource)
}
})
}
}
func TestCalculateAttorneyFees(t *testing.T) {
fc := NewFeeCalculator()
// 1 RA at LG, 1M Streitwert, 1 client, oral hearing, no VAT
bd, err := fc.CalculateAttorneyFees(1000000, "2025", 1.3, 1.2, 1, 1, true, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if bd == nil {
t.Fatal("expected non-nil breakdown")
}
// RVG base at 1M = 5502
if math.Abs(bd.BaseFee-5502) > 0.01 {
t.Errorf("base fee = %v, want 5502", bd.BaseFee)
}
// VG = 1.3 * 5502 = 7152.6
if math.Abs(bd.VGFee-7152.6) > 0.01 {
t.Errorf("VG fee = %v, want 7152.6", bd.VGFee)
}
// No increase (1 client)
if bd.IncreaseFee != 0 {
t.Errorf("increase fee = %v, want 0", bd.IncreaseFee)
}
// TG = 1.2 * 5502 = 6602.4
if math.Abs(bd.TGFee-6602.4) > 0.01 {
t.Errorf("TG fee = %v, want 6602.4", bd.TGFee)
}
// Subtotal net = 7152.6 + 0 + 6602.4 + 20 = 13775
if math.Abs(bd.SubtotalNet-13775) > 0.01 {
t.Errorf("subtotal net = %v, want 13775", bd.SubtotalNet)
}
}
func TestCalculateAttorneyFees_WithVAT(t *testing.T) {
fc := NewFeeCalculator()
bd, err := fc.CalculateAttorneyFees(1000000, "2025", 1.3, 1.2, 1, 1, true, 0.19)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Net = 13775, VAT = 13775 * 0.19 = 2617.25
wantVAT := 13775 * 0.19
if math.Abs(bd.VAT-wantVAT) > 0.01 {
t.Errorf("VAT = %v, want %v", bd.VAT, wantVAT)
}
wantGross := 13775 + wantVAT
if math.Abs(bd.SubtotalGross-wantGross) > 0.01 {
t.Errorf("subtotal gross = %v, want %v", bd.SubtotalGross, wantGross)
}
}
func TestCalculateAttorneyFees_MultipleClients(t *testing.T) {
fc := NewFeeCalculator()
// 3 clients → increase factor = min((3-1)*0.3, 2.0) = 0.6
bd, err := fc.CalculateAttorneyFees(1000000, "2025", 1.3, 1.2, 1, 3, true, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wantIncrease := 0.6 * 5502 // 3301.2
if math.Abs(bd.IncreaseFee-wantIncrease) > 0.01 {
t.Errorf("increase fee = %v, want %v", bd.IncreaseFee, wantIncrease)
}
}
func TestCalculateAttorneyFees_IncreaseCapped(t *testing.T) {
fc := NewFeeCalculator()
// 10 clients → factor = min((10-1)*0.3, 2.0) = min(2.7, 2.0) = 2.0
bd, err := fc.CalculateAttorneyFees(1000000, "2025", 1.3, 1.2, 1, 10, true, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wantIncrease := 2.0 * 5502 // 11004
if math.Abs(bd.IncreaseFee-wantIncrease) > 0.01 {
t.Errorf("increase fee = %v, want %v (should be capped at 2.0x)", bd.IncreaseFee, wantIncrease)
}
}
func TestCalculateAttorneyFees_NoHearing(t *testing.T) {
fc := NewFeeCalculator()
bd, err := fc.CalculateAttorneyFees(1000000, "2025", 1.3, 1.2, 1, 1, false, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if bd.TGFee != 0 {
t.Errorf("TG fee = %v, want 0 (no hearing)", bd.TGFee)
}
}
func TestCalculateAttorneyFees_ZeroAttorneys(t *testing.T) {
fc := NewFeeCalculator()
bd, err := fc.CalculateAttorneyFees(1000000, "2025", 1.3, 1.2, 0, 1, true, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if bd != nil {
t.Error("expected nil breakdown for 0 attorneys")
}
}
func TestCalculateInstanceTotal_ExpertFees(t *testing.T) {
fc := NewFeeCalculator()
// Bug 3 fix: expert fees must be included in court subtotal
inst := models.InstanceInput{
Type: models.InstanceBPatG,
Enabled: true,
FeeVersion: "2025",
NumAttorneys: 1,
NumPatentAttorneys: 1,
NumClients: 1,
OralHearing: true,
ExpertFees: 10000,
}
result, err := fc.CalculateInstanceTotal(1000000, inst, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Court subtotal should include expert fees
wantCourtSubtotal := result.CourtFee + 10000
if math.Abs(result.CourtSubtotal-wantCourtSubtotal) > 0.01 {
t.Errorf("court subtotal = %v, want %v (should include expert fees)", result.CourtSubtotal, wantCourtSubtotal)
}
// Instance total should include expert fees
if result.InstanceTotal < result.CourtFee+10000 {
t.Errorf("instance total %v should include expert fees (court fee %v + expert 10000)", result.InstanceTotal, result.CourtFee)
}
}
func TestCalculateSecurityForCosts_BugFixes(t *testing.T) {
fc := NewFeeCalculator()
// Bug 1: VAT should be added (1 + VAT), not subtracted
sec, err := fc.CalculateSecurityForCosts(1000000, "2025", 1, 0.19)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sec.TotalGross < sec.SubtotalNet {
t.Errorf("Bug 1: total %v should be > subtotal %v (VAT should add, not subtract)", sec.TotalGross, sec.SubtotalNet)
}
wantTotal := sec.SubtotalNet * 1.19
if math.Abs(sec.TotalGross-wantTotal) > 0.01 {
t.Errorf("Bug 1: total = %v, want %v", sec.TotalGross, wantTotal)
}
// Bug 2: verify inst2 uses GKG base, not RVG base
// At 1M: GKG base = 6238, RVG base = 5502
// inst2 includes "4.0x court" = 4.0 * GKG_base = 24952
// If it incorrectly used RVG: 4.0 * 5502 = 22008
rvgBase := 5502.0
gkgBase := 6238.0
expectedInst2 := 2.8*rvgBase + 2.8*rvgBase + 4.0*gkgBase + 5000
if math.Abs(sec.Instance2-expectedInst2) > 0.01 {
t.Errorf("Bug 2: instance2 = %v, want %v (should use GKG base for court fee)", sec.Instance2, expectedInst2)
}
}
func TestCalculateSecurityForCosts_ZeroVAT(t *testing.T) {
fc := NewFeeCalculator()
sec, err := fc.CalculateSecurityForCosts(1000000, "2025", 1, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// With 0% VAT, total should equal subtotal (bug is invisible)
if sec.TotalGross != sec.SubtotalNet {
t.Errorf("with 0%% VAT: total %v should equal subtotal %v", sec.TotalGross, sec.SubtotalNet)
}
}
func TestCalculateFullLitigation_Infringement(t *testing.T) {
fc := NewFeeCalculator()
req := models.FeeCalculateRequest{
Streitwert: 1000000,
VATRate: 0,
ProceedingPath: models.PathInfringement,
Instances: []models.InstanceInput{
{Type: models.InstanceLG, Enabled: true, FeeVersion: "2025", NumAttorneys: 1, NumPatentAttorneys: 1, NumClients: 1, OralHearing: true},
{Type: models.InstanceOLG, Enabled: true, FeeVersion: "2025", NumAttorneys: 1, NumPatentAttorneys: 1, NumClients: 1, OralHearing: true},
{Type: models.InstanceBGHNZB, Enabled: true, FeeVersion: "2025", NumAttorneys: 1, NumPatentAttorneys: 1, NumClients: 1, OralHearing: true},
{Type: models.InstanceBGHRev, Enabled: false, FeeVersion: "2025", NumAttorneys: 1, NumPatentAttorneys: 1, NumClients: 1, OralHearing: true},
},
}
resp, err := fc.CalculateFullLitigation(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// 3 enabled instances
if len(resp.Instances) != 3 {
t.Errorf("got %d instances, want 3", len(resp.Instances))
}
// Should have at least "Gesamtkosten bei Nichtzulassung"
found := false
for _, total := range resp.Totals {
if total.Label == "Gesamtkosten bei Nichtzulassung" {
found = true
if total.Total <= 0 {
t.Errorf("Nichtzulassung total = %v, should be > 0", total.Total)
}
}
}
if !found {
t.Error("missing 'Gesamtkosten bei Nichtzulassung' total")
}
}
func TestCalculateFullLitigation_Nullity(t *testing.T) {
fc := NewFeeCalculator()
req := models.FeeCalculateRequest{
Streitwert: 1000000,
VATRate: 0,
ProceedingPath: models.PathNullity,
Instances: []models.InstanceInput{
{Type: models.InstanceBPatG, Enabled: true, FeeVersion: "2025", NumAttorneys: 1, NumPatentAttorneys: 1, NumClients: 1, OralHearing: true},
{Type: models.InstanceBGHNull, Enabled: true, FeeVersion: "2025", NumAttorneys: 1, NumPatentAttorneys: 1, NumClients: 1, OralHearing: true},
},
}
resp, err := fc.CalculateFullLitigation(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.Instances) != 2 {
t.Errorf("got %d instances, want 2", len(resp.Instances))
}
if len(resp.Totals) != 1 || resp.Totals[0].Label != "Gesamtkosten Nichtigkeitsverfahren" {
t.Errorf("unexpected totals: %+v", resp.Totals)
}
}
func TestGetSchedules(t *testing.T) {
fc := NewFeeCalculator()
schedules := fc.GetSchedules()
if len(schedules) != 5 {
t.Errorf("got %d schedules, want 5", len(schedules))
}
// Aktuell should be an alias
last := schedules[len(schedules)-1]
if !last.IsAlias || last.AliasOf != "2025" {
t.Errorf("Aktuell should be alias of 2025, got IsAlias=%v AliasOf=%v", last.IsAlias, last.AliasOf)
}
}

View File

@@ -0,0 +1,249 @@
package services
import "math"
// feeBracket defines one bracket in the GKG/RVG fee schedule.
// Each bracket covers a range of Streitwert values.
type feeBracket struct {
UpperBound float64 // upper bound of this bracket
StepSize float64 // step size within this bracket
GKGIncrement float64 // GKG fee increment per step
RVGIncrement float64 // RVG fee increment per step
}
// feeScheduleData holds the bracket data for a fee schedule version.
type feeScheduleData struct {
Label string
ValidFrom string
Brackets []feeBracket
AliasOf string // if non-empty, this schedule is an alias for another
}
// feeSchedules contains all historical GKG/RVG fee schedule versions.
// Data extracted from Patentprozesskostenrechner.xlsm ListObjects.
var feeSchedules = map[string]*feeScheduleData{
"2005": {
Label: "GKG/RVG 2006-09-01",
ValidFrom: "2006-09-01",
Brackets: []feeBracket{
{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},
{math.MaxFloat64, 50000, 150, 150},
},
},
"2013": {
Label: "GKG/RVG 2013-08-01",
ValidFrom: "2013-08-01",
Brackets: []feeBracket{
{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},
{math.MaxFloat64, 50000, 180, 150},
},
},
"2021": {
Label: "GKG/RVG 2021-01-01",
ValidFrom: "2021-01-01",
Brackets: []feeBracket{
{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},
{math.MaxFloat64, 50000, 198, 165},
},
},
"2025": {
Label: "GKG/RVG 2025-06-01",
ValidFrom: "2025-06-01",
Brackets: []feeBracket{
{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},
{math.MaxFloat64, 50000, 210, 175},
},
},
"Aktuell": {
Label: "Aktuell (= 2025-06-01)",
ValidFrom: "2025-06-01",
AliasOf: "2025",
},
}
// instanceConfig holds the multipliers and fee basis for each court instance.
type instanceConfig struct {
Label string
CourtFactor float64 // multiplier for base court fee
CourtSource string // "GKG", "PatKostG", or "fixed"
FixedCourtFee float64 // only used when CourtSource is "fixed"
RAVGFactor float64 // Rechtsanwalt Verfahrensgebühr factor
RATGFactor float64 // Rechtsanwalt Terminsgebühr factor
PAVGFactor float64 // Patentanwalt Verfahrensgebühr factor
PATGFactor float64 // Patentanwalt Terminsgebühr factor
HasPA bool // whether patent attorneys are applicable
}
// instanceConfigs defines the fee parameters for each court instance type.
var instanceConfigs = map[string]instanceConfig{
"LG": {"LG (Verletzung 1. Instanz)", 3.0, "GKG", 0, 1.3, 1.2, 1.3, 1.2, true},
"OLG": {"OLG (Berufung)", 4.0, "GKG", 0, 1.6, 1.2, 1.6, 1.2, true},
"BGH_NZB": {"BGH (Nichtzulassungsbeschwerde)", 2.0, "GKG", 0, 2.3, 1.2, 1.6, 1.2, true},
"BGH_Rev": {"BGH (Revision)", 5.0, "GKG", 0, 2.3, 1.5, 1.6, 1.5, true},
"BPatG": {"BPatG (Nichtigkeitsverfahren)", 4.5, "PatKostG", 0, 1.3, 1.2, 1.3, 1.2, true},
"BGH_Null": {"BGH (Nichtigkeitsberufung)", 6.0, "GKG", 0, 1.6, 1.5, 1.6, 1.5, true},
"DPMA": {"DPMA (Löschungsverfahren)", 0, "fixed", 300, 1.3, 1.2, 0, 0, false},
"BPatG_Canc": {"BPatG (Löschungsbeschwerde)", 0, "fixed", 500, 1.3, 1.2, 0, 0, false},
}
// --- UPC Fee Data ---
// upcFeeBracket defines one bracket in a UPC fee table.
// MaxValue 0 means unlimited (last bracket).
type upcFeeBracket struct {
MaxValue float64
Fee float64
}
type upcFixedFees struct {
Infringement float64
CounterclaimInfringement float64
NonInfringement float64
LicenseCompensation float64
DetermineDamages float64
RevocationStandalone float64
CounterclaimRevocation float64
ProvisionalMeasures float64
}
type upcScheduleData struct {
Label string
ValidFrom string
FixedFees upcFixedFees
ValueBased []upcFeeBracket
Recoverable []upcFeeBracket
SMReduction float64
}
// upcSchedules contains UPC fee data for pre-2026 and 2026+ schedules.
var upcSchedules = map[string]*upcScheduleData{
"pre2026": {
Label: "UPC (vor 2026)",
ValidFrom: "2023-06-01",
FixedFees: upcFixedFees{
Infringement: 11000,
CounterclaimInfringement: 11000,
NonInfringement: 11000,
LicenseCompensation: 11000,
DetermineDamages: 3000,
RevocationStandalone: 20000,
CounterclaimRevocation: 20000,
ProvisionalMeasures: 11000,
},
ValueBased: []upcFeeBracket{
{500000, 0},
{750000, 2500},
{1000000, 4000},
{1500000, 8000},
{2000000, 13000},
{3000000, 20000},
{4000000, 26000},
{5000000, 32000},
{6000000, 39000},
{7000000, 46000},
{8000000, 52000},
{9000000, 58000},
{10000000, 65000},
{15000000, 75000},
{20000000, 100000},
{25000000, 125000},
{30000000, 150000},
{50000000, 250000},
{0, 325000},
},
Recoverable: []upcFeeBracket{
{250000, 38000},
{500000, 56000},
{1000000, 112000},
{2000000, 200000},
{4000000, 400000},
{8000000, 600000},
{16000000, 800000},
{30000000, 1200000},
{50000000, 1500000},
{0, 2000000},
},
SMReduction: 0.40,
},
"2026": {
Label: "UPC (ab 2026)",
ValidFrom: "2026-01-01",
FixedFees: upcFixedFees{
Infringement: 14600,
CounterclaimInfringement: 14600,
NonInfringement: 14600,
LicenseCompensation: 14600,
DetermineDamages: 4000,
RevocationStandalone: 26500,
CounterclaimRevocation: 26500,
ProvisionalMeasures: 14600,
},
// Estimated ~32% increase on pre-2026 values; replace with official data when available.
ValueBased: []upcFeeBracket{
{500000, 0},
{750000, 3300},
{1000000, 5300},
{1500000, 10600},
{2000000, 17200},
{3000000, 26400},
{4000000, 34300},
{5000000, 42200},
{6000000, 51500},
{7000000, 60700},
{8000000, 68600},
{9000000, 76600},
{10000000, 85800},
{15000000, 99000},
{20000000, 132000},
{25000000, 165000},
{30000000, 198000},
{50000000, 330000},
{0, 429000},
},
Recoverable: []upcFeeBracket{
{250000, 38000},
{500000, 56000},
{1000000, 112000},
{2000000, 200000},
{4000000, 400000},
{8000000, 600000},
{16000000, 800000},
{30000000, 1200000},
{50000000, 1500000},
{0, 2000000},
},
SMReduction: 0.50,
},
}
// Fee calculation constants (RVG)
const (
erhoehungsfaktor = 0.3 // Nr. 1008 VV RVG increase per additional client
erhoehungsfaktorMax = 2.0 // maximum increase factor
auslagenpauschale = 20.0 // Nr. 7002 VV RVG flat expense allowance (EUR)
)

514
docs/kostenrechner-plan.md Normal file
View File

@@ -0,0 +1,514 @@
# Patentprozesskostenrechner — Implementation Plan
**Date:** 2026-03-31
**Source:** Analysis of `Patentprozesskostenrechner.xlsm` (c) 2021 M. Siebels
**Status:** Research complete, ready for implementation
---
## 1. Fee Calculation Logic Summary
The calculator computes costs for German patent litigation using two fee systems:
### GKG (Gerichtskostengesetz) — Court Fees
A **step-based accumulator**. The Streitwert is divided into brackets, each with a step size and per-step increment. The algorithm:
1. Start with the minimum fee (first row of the fee table)
2. For each bracket: compute `steps = ceil(portion_in_bracket / step_size)`
3. Accumulate: `fee += steps * increment`
4. Result = "einfache Gebühr" (1.0x base fee)
5. Multiply by the instance-specific factor (e.g., 3.0x for LG, 4.0x for OLG)
For Streitwert > EUR 500,000 (post-2025 schedule): `base = 4,138 + ceil((Streitwert - 500,000) / 50,000) * 210`
### RVG (Rechtsanwaltsvergütungsgesetz) — Attorney Fees
Same step-based lookup but with its own column in the fee table. Per attorney, the formula is:
```
attorney_cost = (VG_factor * base_RVG + increase_fee + TG_factor * base_RVG + Pauschale) * (1 + VAT)
```
Where:
- **VG** = Verfahrensgebühr (procedural fee): 1.3x (LG/BPatG), 1.6x (OLG/BGH nullity), 2.3x (BGH NZB/Rev for RA)
- **TG** = Terminsgebühr (hearing fee): 1.2x or 1.5x (BGH), only if hearing held
- **Increase fee** (Nr. 1008 VV RVG): `MIN((clients - 1) * 0.3, 2.0) * base_RVG` for multiple clients
- **Pauschale** = EUR 20 (Auslagenpauschale Nr. 7002 VV RVG)
### PatKostG — Patent Court Fees
BPatG nullity uses PatKostG instead of GKG for court fees (but same step-based lookup from the same table). DPMA/BPatG cancellation uses fixed fees (EUR 300 / EUR 500).
### Instance Multipliers (Complete Reference)
| Instance | Court Fee Factor | Source | Fee Basis |
|---|---|---|---|
| **LG** (infringement 1st) | 3.0x GKG | Nr. 1210 Anl. 1 GKG | GKG |
| **OLG** (infringement appeal) | 4.0x GKG | Nr. 1420 KV GKG | GKG |
| **BGH NZB** (leave to appeal) | 2.0x GKG | Nr. 1242 KV GKG | GKG |
| **BGH Revision** | 5.0x GKG | Nr. 1230 KV GKG | GKG |
| **BPatG** (nullity 1st) | 4.5x | Nr. 402 100 Anl. PatKostG | PatKostG |
| **BGH** (nullity appeal) | 6.0x GKG | Nr. 1250 KV GKG | GKG |
| **DPMA** (cancellation) | EUR 300 flat | Nr. 323 100 Anl. PatKostG | Fixed |
| **BPatG** (cancellation appeal) | EUR 500 flat | Nr. 401 100 Anl. PatKostG | Fixed |
| Instance | RA VG Factor | RA TG Factor | PA VG Factor | PA TG Factor |
|---|---|---|---|---|
| **LG** | 1.3x | 1.2x | 1.3x | 1.2x |
| **OLG** | 1.6x | 1.2x | 1.6x | 1.2x |
| **BGH NZB** | 2.3x | 1.2x | 1.6x | 1.2x |
| **BGH Revision** | 2.3x | 1.5x | 1.6x | 1.5x |
| **BPatG** (nullity) | 1.3x | 1.2x | 1.3x | 1.2x |
| **BGH** (nullity appeal) | 1.6x | 1.5x | 1.6x | 1.5x |
| **DPMA** | 1.3x | 1.2x | — | — |
| **BPatG** (cancellation) | 1.3x | 1.2x | — | — |
---
## 2. Fee Schedule Data (JSON)
Five historical versions of the fee table. Each row: `[upperBound, stepSize, gkgIncrement, rvgIncrement]`.
Each row: `[upperBound, stepSize, gkgIncrement, rvgIncrement]`. Values extracted directly from the Excel ListObjects. Note: increments are decimal (e.g., 51.5 EUR per step). The last bracket uses a very large upper bound (effectively infinity).
```json
{
"feeSchedules": {
"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"
}
},
"constants": {
"erhoehungsfaktor": 0.3,
"erhoehungsfaktorMax": 2.0,
"auslagenpauschale": 20
}
}
```
**Notes on the data:**
- The 2005 version has 9 brackets (starts at 300, not 500). Older versions differ more than expected.
- Increments are **not integers** in the 2025 version (e.g., 51.5, 41.5, 59.5) — the implementation must handle decimal arithmetic.
- The last bracket upper bound is `1e+20` in the Excel (effectively infinity). Use `Infinity` in TypeScript or a sentinel value.
- "Aktuell" is currently identical to "2025" — implement as an alias that can diverge when fees are next updated.
### UPC Fee Data (New — Not in Excel)
```json
{
"upcFees": {
"pre2026": {
"label": "UPC (vor 2026)",
"validFrom": "2023-06-01",
"fixedFees": {
"infringement": 11000,
"counterclaim_infringement": 11000,
"non_infringement": 11000,
"license_compensation": 11000,
"determine_damages": 3000,
"revocation_standalone": 20000,
"counterclaim_revocation": 20000,
"provisional_measures": 11000
},
"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 }
],
"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 }
],
"smReduction": 0.40
},
"2026": {
"label": "UPC (ab 2026)",
"validFrom": "2026-01-01",
"fixedFees": {
"infringement": 14600,
"counterclaim_infringement": 14600,
"non_infringement": 14600,
"license_compensation": 14600,
"determine_damages": 4000,
"revocation_standalone": 26500,
"counterclaim_revocation": 26500,
"provisional_measures": 14600
},
"valueBased": "TODO: exact 2026 table not yet published in extractable form. Estimated ~32% increase on pre-2026 values. Replace with official data when available.",
"smReduction": 0.50
}
}
}
```
---
## 3. Architecture Decision
### Recommendation: New page within KanzlAI-mGMT at `/kosten/rechner`
**Reasons:**
1. **Existing infrastructure**: KanzlAI already has billing/cost infrastructure (time tracking, invoices, RVG rates). The Kostenrechner is a natural extension.
2. **Shared UI patterns**: Sidebar nav, card layout, Tailwind styling, Recharts for any comparison charts — all already established.
3. **Future integration**: Cost calculations can link to cases (attach estimated costs to a case), feed into Prozesskostensicherheit calculations, and inform billing.
4. **No auth required for core calculator**: The page can work without login (public tool for marketing), but logged-in users get case-linking and save functionality.
5. **No backend needed initially**: All fee calculations are deterministic lookups + arithmetic — pure frontend. Data lives as static JSON/TypeScript constants.
**Against standalone deployment:**
- Maintaining a separate deploy adds operational overhead for zero benefit
- Can't integrate with cases or billing later without cross-origin complexity
- Duplicates styling/build tooling
### Proposed Route Structure
```
/kosten/ — Overview page (links to sub-calculators)
/kosten/rechner — Patentprozesskostenrechner (main calculator)
/kosten/rechner/vergleich — (future) Venue comparison tool (DE vs UPC)
```
### Frontend Architecture
```
frontend/src/
app/(app)/kosten/
page.tsx — Overview
rechner/
page.tsx — Calculator page (client component)
lib/
costs/
fee-tables.ts — All fee schedule data (GKG, RVG, UPC)
calculator.ts — Pure calculation functions
types.ts — TypeScript types for inputs/outputs
components/
costs/
CostCalculator.tsx — Main calculator component
InstanceCard.tsx — Per-instance input card (LG, OLG, etc.)
CostSummary.tsx — Results display with breakdown
CostComparison.tsx — (future) Side-by-side venue comparison
```
**No backend changes needed.** All calculation logic is client-side. If we later want to save calculations to a case, we add one API endpoint.
---
## 4. All Inputs
### Global Inputs
| Input | Type | Range | Default | Description |
|---|---|---|---|---|
| `vatRate` | enum | 0%, 16%, 19% | 0% | Umsatzsteuer |
| `streitwert` | number | 50030,000,000 | 100,000 | Amount in dispute (EUR) |
| `erhoehungsStreitwert` | number | >= streitwert | = streitwert | Increased amount (for Erhoehungsgebuehr) |
| `proceedingType` | enum | infringement, nullity, cancellation, security | infringement | Which proceeding to calculate |
### Per-Instance Inputs (Infringement: LG, OLG, BGH-NZB, BGH-Rev)
| Input | Type | Default | Description |
|---|---|---|---|
| `enabled` | boolean | true (LG), false (others) | Include this instance |
| `feeVersion` | enum | "Aktuell" | Fee schedule version (2005/2013/2021/2025/Aktuell) |
| `numAttorneys` | integer >= 0 | 1 | Rechtsanwälte |
| `numPatentAttorneys` | integer >= 0 | 1 | Patentanwälte |
| `oralHearing` | boolean | true | Mündliche Verhandlung held? |
| `expertFees` | number >= 0 | 0 | Sachverständigenvergütung (EUR) |
| `terminationType` | enum | "Urteil" | How case ended (Urteil/Vergleich/Klagerücknahme/etc.) |
| `numClients` | integer >= 1 | 1 | Mandanten (for Erhöhungsgebühr) |
### Per-Instance Inputs (Nullity: BPatG, BGH)
Same structure as infringement instances.
### Per-Instance Inputs (Cancellation: DPMA, BPatG)
Same structure but no patent attorneys at DPMA level, fixed court fees.
### UPC-Specific Inputs (New)
| Input | Type | Default | Description |
|---|---|---|---|
| `actionType` | enum | "infringement" | UPC action type (affects fixed fee + value-based applicability) |
| `feeVersion` | enum | "2026" | pre-2026 or 2026 |
| `isSME` | boolean | false | Small/micro enterprise (40%/50% court fee reduction) |
| `includeAppeal` | boolean | false | Include Court of Appeal |
| `includeRevocation` | boolean | false | Include counterclaim for revocation |
---
## 5. All Outputs
### Per-Instance Breakdown
| Output | Description |
|---|---|
| Court fee (base) | e.g., "3.0x GKG = EUR 18,714" |
| Expert fees | If applicable |
| **Court subtotal** | Court fee + expert fees |
| Per-attorney cost | VG + Erhöhung + TG + Pauschale, before VAT |
| Per-attorney cost (incl. VAT) | × (1 + VAT) |
| Attorney subtotal | Per-attorney × num_attorneys |
| Patent attorney subtotal | Same calculation × num_patent_attorneys |
| **Instance total** | Court subtotal + attorney subtotal + patent attorney subtotal |
### Summary Totals (Infringement)
| Output | Description |
|---|---|
| Gesamtkosten bei Nichtzulassung | LG + OLG + BGH-NZB |
| Gesamtkosten bei Revision | LG + OLG + BGH-Rev |
### Summary Totals (Nullity)
| Output | Description |
|---|---|
| Gesamtkosten Nichtigkeitsverfahren | BPatG + BGH |
### Summary Totals (Cancellation)
| Output | Description |
|---|---|
| Gesamtkosten Löschungsverfahren | DPMA + BPatG |
### Security for Costs (Prozesskostensicherheit)
| Output | Description |
|---|---|
| 1. Instanz | 2.5x RA + increase + 2.5x PA + increase + EUR 5,000 |
| 2. Instanz | 2.8x RA + increase + 2.8x PA + increase + 4.0x court + EUR 5,000 |
| NZB | 2.3x RA + increase + 2.3x PA + increase |
| Total (incl. VAT) | Sum × (1 + VAT) |
### UPC Outputs (New)
| Output | Description |
|---|---|
| Fixed court fee | Per action type |
| Value-based fee | Per Streitwert bracket |
| Total court fees | Fixed + value-based |
| Court fees (SME) | With 40%/50% reduction |
| Recoverable costs ceiling | Per Streitwert bracket |
| Appeal court fees | If appeal enabled |
| **Total cost risk** | All court fees + recoverable costs ceiling |
### Fee Schedule Reference (Quick Lookup)
| Output | Description |
|---|---|
| Base 1.0x GKG fee | For each fee version at given Streitwert |
| Base 1.0x RVG fee | For each fee version at given Streitwert |
---
## 6. Bugs to Fix (from Excel)
### Bug 1: Prozesskostensicherheit VAT Formula (CRITICAL)
- **Location:** Excel cell C31 on Prozesskostensicherheit sheet
- **Problem:** Formula `=C30*(1-Umsatzsteuer)` subtracts VAT instead of adding it
- **Label says:** "inkl. Umsatzsteuer" (including VAT)
- **Fix:** `=C30*(1+Umsatzsteuer)` → in web version: `total * (1 + vatRate)`
- **Impact:** 32% error when VAT = 19% (EUR 35,394 vs correct EUR 51,998)
- **Why hidden:** Default VAT is 0%, so `1-0 = 1+0 = 1` — bug only manifests with non-zero VAT
### Bug 2: Prozesskostensicherheit Uses Wrong Fee Type
- **Location:** Excel cell C22 on Prozesskostensicherheit sheet
- **Problem:** `mGebuehrensatz(Streitwert, 1, SelectedVersion)` — parameter `1` selects RVG (attorney) fees
- **Should be:** `mGebuehrensatz(Streitwert, 0, SelectedVersion)` — parameter `0` selects GKG (court) fees
- **Context:** This cell calculates "4-fache Gerichts-Verfahrensgebühr (Nr. 1420 KV)" — clearly a court fee
- **Fix:** Use GKG fee schedule for court fee calculations
- **Impact:** GKG and RVG fees differ, so the result is always wrong
### Bug 3: Nichtigkeitsverfahren Missing Expert Fees in Total
- **Location:** Excel cell D24 on Nichtigkeitsverfahren sheet
- **Problem:** `=C23+C13` adds attorney total (C23) + bare court fee (C13), but C13 is only the 4.5x fee line item
- **Should be:** `=C23+C15` where C15 is the Zwischensumme (court fees + expert fees)
- **Fix:** Include expert fees subtotal in instance total
- **Impact:** Expert fees are silently dropped from the BPatG total. Consistent with Verletzungsverfahren pattern where D26 = C25 + C17 (uses Zwischensumme)
---
## 7. UPC Fee Structure (New Feature)
### How UPC Fees Differ from German Courts
| Aspect | German Courts (GKG/RVG) | UPC |
|---|---|---|
| **Court fee model** | Step-based accumulator | Fixed fee + bracket lookup |
| **Attorney fees** | RVG statutory table | Contractual (market rates) |
| **Recoverable costs** | RVG-based (predictable) | Ceiling table (much higher) |
| **Scope** | Single country | Pan-European |
| **Nullity** | Separate BPatG proceeding | Counterclaim in same action |
### UPC Court Fees: Two Components
1. **Fixed fee** — always due, depends on action type:
- Infringement: EUR 14,600 (2026) / EUR 11,000 (pre-2026)
- Revocation: EUR 26,500 (2026) / EUR 20,000 (pre-2026) — flat, no value-based component
- Appeal: same fixed fees as first instance
2. **Value-based fee** — only for infringement-type actions when Streitwert > EUR 500,000:
- 19 brackets from EUR 0 (≤500k) to EUR 325,000 (>50M) — pre-2026
- ~32% increase in 2026 (exact table pending official publication)
- Revocation actions have NO value-based fee
3. **SME reduction**: 50% (2026) / 40% (pre-2026) on all court fees
### Recoverable Costs Ceilings (Attorney Fee Caps)
Per instance, the losing party reimburses up to:
| Streitwert | Ceiling |
|---|---|
| ≤ EUR 250,000 | EUR 38,000 |
| ≤ EUR 500,000 | EUR 56,000 |
| ≤ EUR 1,000,000 | EUR 112,000 |
| ≤ EUR 2,000,000 | EUR 200,000 |
| ≤ EUR 4,000,000 | EUR 400,000 |
| ≤ EUR 8,000,000 | EUR 600,000 |
| ≤ EUR 16,000,000 | EUR 800,000 |
| ≤ EUR 30,000,000 | EUR 1,200,000 |
| ≤ EUR 50,000,000 | EUR 1,500,000 |
| > EUR 50,000,000 | EUR 2,000,000 |
Court can raise ceiling by 50% (cases ≤1M) or 25% (150M). Expert/translator fees recoverable separately on top.
### Cost Comparison (Key Insight)
At EUR 3M Streitwert (infringement, 1st instance):
| | UPC (2026) | German LG |
|---|---|---|
| Court fees | ~EUR 41,000 | EUR 43,914 |
| Recoverable attorney costs | up to EUR 400,000 | ~EUR 100,388 |
| **Total cost risk** | **~EUR 441,000** | **~EUR 144,302** |
**Key takeaway:** UPC court fees are comparable to or lower than German courts. But recoverable attorney costs are 35x higher, making total cost risk at UPC roughly 23x German courts. This is the critical information patent litigators need.
### UPC Sources
- Rule 370 RoP (court fees), Rule 152 RoP (recoverable costs), Art. 69 UPCA
- UPC Administrative Committee fee table AC/05/08072022 (pre-2026)
- UPC Administrative Committee amendment, 4 Nov 2025 (2026 changes)
- Scale of Ceilings for Recoverable Costs: D-AC/10/24042023
- Maiwald MAIinsight April 2025 (practitioner analysis with verified figures)
---
## 8. Implementation Recommendations
### Phase 1: Core Calculator (MVP)
- Implement `fee-tables.ts` with all 5 GKG/RVG schedule versions as typed constants
- Implement `calculator.ts` with pure functions: `computeBaseFee(streitwert, isRVG, version)`, per-instance calculations, totals
- Build the UI as a single `"use client"` page at `/kosten/rechner`
- Card-based layout: global inputs at top, collapsible instance cards, results summary at bottom
- Fix all 3 bugs in the implementation (don't port them from Excel)
- German language UI throughout
### Phase 2: UPC Extension
- Add UPC fee data and calculation functions (bracket lookup, not step-based)
- Add UPC section to the calculator (separate card or tab)
- Add venue comparison view: side-by-side DE vs UPC for the same Streitwert
### Phase 3: Integration
- Allow saving calculations to a case (requires one backend endpoint)
- PDF export of cost breakdown
- Wire up Verfahrensbeendigung (termination type affects fee multipliers)
### Data Extraction TODO
Before implementation begins, the exact fee table values must be extracted from the Excel file. The analysis doc describes the structure but doesn't list every cell value. The implementer should either:
1. Open the Excel and manually read the Hebesaetze sheet values, or
2. Use a Python script with `openpyxl` to extract the ListObject data programmatically
This is critical — the older fee versions (2005, 2013, 2021) have different step sizes and increments.

View File

@@ -0,0 +1,391 @@
# UPC Fee Structure Research
> Research date: 2026-03-31
> Status: Complete (pre-2026 tables verified from official sources; 2026 amendments documented with confirmed changes)
> Purpose: Data for implementing a patent litigation cost calculator
## Overview
The UPC fee system consists of:
1. **Fixed fees** (always due, depend on action type)
2. **Value-based fees** (additional, for infringement-type actions with value > EUR 500,000)
3. **Recoverable costs ceilings** (caps on lawyer fees the losing party must reimburse)
Legal basis: Rule 370 RoP (court fees), Rule 152 RoP (recoverable costs), Art. 69 UPCA (cost allocation).
---
## 1. Fixed Court Fees (Court of First Instance)
### Pre-2026 Schedule (actions filed before 1 Jan 2026)
| Action Type | Fixed Fee |
|---|---|
| Infringement action | EUR 11,000 |
| Counterclaim for infringement | EUR 11,000 |
| Declaration of non-infringement | EUR 11,000 |
| Compensation for license of right | EUR 11,000 |
| Application to determine damages | EUR 3,000 |
| **Standalone revocation action** | **EUR 20,000** (flat, no value-based fee) |
| **Counterclaim for revocation** | **EUR 20,000** (flat, no value-based fee) |
| Application for provisional measures | EUR 11,000 |
| Order to preserve evidence | EUR 350 |
| Order for inspection | EUR 350 |
| Order to freeze assets | EUR 1,000 |
| Protective letter filing | EUR 200 |
| Protective letter extension (per 6 months) | EUR 100 |
| Action against EPO decision | EUR 1,000 |
| Re-establishment of rights | EUR 350 |
| Review case management order | EUR 300 |
| Set aside decision by default | EUR 1,000 |
### 2026 Schedule (actions filed from 1 Jan 2026)
~33% increase across the board:
| Action Type | Old Fee | New Fee (2026) | Change |
|---|---|---|---|
| Infringement action | EUR 11,000 | **EUR 14,600** | +33% |
| Counterclaim for infringement | EUR 11,000 | **EUR 14,600** | +33% |
| Declaration of non-infringement | EUR 11,000 | **EUR 14,600** | +33% |
| Standalone revocation action | EUR 20,000 | **EUR 26,500** | +33% |
| Counterclaim for revocation | EUR 20,000 | **EUR 26,500** | +33% |
| Application for provisional measures | EUR 11,000 | **EUR 14,600** | +33% |
| Application to determine damages | EUR 3,000 | **EUR 4,000** | +33% |
| Order to preserve evidence | EUR 350 | **EUR 5,000** | +1,329% |
| Order for inspection | EUR 350 | **EUR 5,000** | +1,329% |
| Order to freeze assets | EUR 1,000 | **EUR 5,000** | +400% |
| Protective letter filing | EUR 200 | **EUR 300** | +50% |
| Protective letter extension | EUR 100 | **EUR 130** | +30% |
| Application for rehearing | EUR 2,500 | **EUR 14,600** | +484% |
**Key 2026 change**: Provisional measures now also have a **value-based fee** (new). The value is deemed to be 2/3 of the value of the merits proceedings.
---
## 2. Value-Based Fees (Additional to Fixed Fee)
Applies to: infringement actions, counterclaim for infringement, declaration of non-infringement, compensation for license of right, application to determine damages.
Does NOT apply to: revocation actions (standalone or counterclaim) -- these are flat fee only.
### Pre-2026 Value-Based Fee Table
| Value of the Action | Additional Value-Based Fee |
|---|---|
| <= EUR 500,000 | EUR 0 |
| <= EUR 750,000 | EUR 2,500 |
| <= EUR 1,000,000 | EUR 4,000 |
| <= EUR 1,500,000 | EUR 8,000 |
| <= EUR 2,000,000 | EUR 13,000 |
| <= EUR 3,000,000 | EUR 20,000 |
| <= EUR 4,000,000 | EUR 26,000 |
| <= EUR 5,000,000 | EUR 32,000 |
| <= EUR 6,000,000 | EUR 39,000 |
| <= EUR 7,000,000 | EUR 46,000 |
| <= EUR 8,000,000 | EUR 52,000 |
| <= EUR 9,000,000 | EUR 58,000 |
| <= EUR 10,000,000 | EUR 65,000 |
| <= EUR 15,000,000 | EUR 75,000 |
| <= EUR 20,000,000 | EUR 100,000 |
| <= EUR 25,000,000 | EUR 125,000 |
| <= EUR 30,000,000 | EUR 150,000 |
| <= EUR 50,000,000 | EUR 250,000 |
| > EUR 50,000,000 | EUR 325,000 |
Source: Maiwald MAIinsight April 2025 (verified against commentedupc.com and official UPC fee table AC/05/08072022).
### 2026 Value-Based Fee Table (estimated)
The 2026 amendment increases value-based fees by ~32% at first instance and ~45% on appeal. Applying the 32% increase:
| Value of the Action | Old Fee | New Fee (est. ~32%) |
|---|---|---|
| <= EUR 500,000 | EUR 0 | EUR 0 |
| <= EUR 750,000 | EUR 2,500 | ~EUR 3,300 |
| <= EUR 1,000,000 | EUR 4,000 | ~EUR 5,300 |
| <= EUR 1,500,000 | EUR 8,000 | ~EUR 10,600 |
| <= EUR 2,000,000 | EUR 13,000 | ~EUR 17,200 |
| <= EUR 3,000,000 | EUR 20,000 | ~EUR 26,400 |
| <= EUR 5,000,000 | EUR 32,000 | ~EUR 42,200 |
| <= EUR 10,000,000 | EUR 65,000 | ~EUR 85,800 |
| <= EUR 30,000,000 | EUR 150,000 | ~EUR 198,000 |
| <= EUR 50,000,000 | EUR 250,000 | ~EUR 330,000 |
| > EUR 50,000,000 | EUR 325,000 | ~EUR 429,000 |
**Note**: The official consolidated 2026 fee table PDF was not extractable (403 on UPC website). The Maiwald blog confirms: for a dispute valued at EUR 5M, total court fees (fixed + value-based) went from EUR 43,000 to EUR 44,600, suggesting the 2026 value-based fee for <=5M is EUR 30,000 (i.e., 14,600 + 30,000 = 44,600). This is close to the ~32% estimate. When the exact 2026 table becomes available, these estimates should be replaced.
**Verified 2026 data point** (Secerna): For a typical infringement action valued at EUR 2,000,000, total court fees increase from EUR 24,000 to EUR 31,800. This means: new value-based fee for <=2M = 31,800 - 14,600 = EUR 17,200 (vs. old: 13,000 + 11,000 = 24,000).
---
## 3. Appeal Fees (Court of Appeal)
### Pre-2026
| Appeal Type | Fixed Fee | Value-Based Fee |
|---|---|---|
| Appeal on infringement action | EUR 11,000 | Same table as CFI |
| Appeal on counterclaim for infringement | EUR 11,000 | Same table as CFI |
| Appeal on non-infringement declaration | EUR 11,000 | Same table as CFI |
| Appeal on license compensation | EUR 11,000 | Same table as CFI |
| Appeal on damages determination | EUR 3,000 | Same table as CFI |
| Appeal on revocation action | EUR 20,000 | None |
| Appeal on counterclaim for revocation | Same as first instance | None |
| Appeal on provisional measures | EUR 11,000 | None |
| Appeal on other interlocutory orders | EUR 3,000 | None |
| Application for rehearing | EUR 2,500 | None |
| Appeal on cost decision | EUR 3,000 | None |
| Leave to appeal costs | EUR 1,500 | None |
| Discretionary review request | EUR 350 | None |
### 2026 Appeal Changes
- Fixed fees: ~33% increase (same as CFI)
- Value-based fees: ~45% increase (10% more than CFI increase)
- Revocation appeal: EUR 20,000 -> EUR 29,200 (+46%)
- Rehearing application: EUR 2,500 -> EUR 14,600 (+484%)
---
## 4. Recoverable Costs (Attorney Fee Ceilings)
Per Rule 152(2) RoP and the Administrative Committee's Scale of Ceilings. These are caps on what the winning party can recover from the losing party for legal representation costs. Apply **per instance**.
| Value of the Proceedings | Ceiling for Recoverable Costs |
|---|---|
| <= EUR 250,000 | EUR 38,000 |
| <= EUR 500,000 | EUR 56,000 |
| <= EUR 1,000,000 | EUR 112,000 |
| <= EUR 2,000,000 | EUR 200,000 |
| <= EUR 4,000,000 | EUR 400,000 |
| <= EUR 8,000,000 | EUR 600,000 |
| <= EUR 16,000,000 | EUR 800,000 |
| <= EUR 30,000,000 | EUR 1,200,000 |
| <= EUR 50,000,000 | EUR 1,500,000 |
| > EUR 50,000,000 | EUR 2,000,000 |
### Ceiling Adjustments
The court may **raise** the ceiling upon party request:
- Up to **50% increase** for cases valued up to EUR 1 million
- Up to **25% increase** for cases valued EUR 1-50 million
- Up to an **absolute cap of EUR 5 million** for cases valued above EUR 50 million
The court may **lower** the ceiling if it would threaten the economic existence of a party (SMEs, non-profits, universities, individuals).
### What is recoverable
- Lawyer fees (Rechtsanwalt)
- Patent attorney fees (Patentanwalt)
- Expert fees
- Witness costs
- Interpreter and translator fees
- Note: Expert/translator/witness fees are NOT subject to the ceiling -- they must be reasonable but are recoverable on top of the ceiling
---
## 5. SME / Micro-Enterprise Reductions
### Pre-2026
- Small and micro enterprises: **40% reduction** on all court fees (pay 60%)
- Conditions per Rule 370(8) RoP
### 2026
- Increased to **50% reduction** (pay 50%)
### Legal Aid
- Available in principle under Rule 375 et seq. RoP
---
## 6. Comparison: UPC vs. German National Courts
### German Court Fee Calculation (GKG)
German court fees are calculated using **Anlage 2 zu § 34 GKG**:
- Fee table covers Streitwert up to EUR 500,000 (base fee at 500k: EUR 4,138)
- Above EUR 500,000: fee increases by **EUR 210 per each additional EUR 50,000**
- This gives a "simple fee" (einfache Gebuehr)
- Actual court fees = simple fee x multiplier from Kostenverzeichnis
**Multipliers** for patent cases:
- LG infringement, 1st instance: **3.0x** simple fee
- OLG appeal (Berufung): **4.0x** simple fee
- BPatG nullity, 1st instance: **4.5x** simple fee (separate proceedings!)
- BGH nullity appeal: **6.0x** simple fee
### GKG Simple Fee Calculation for Key Streitwerte
Formula for Streitwert > 500,000: `4,138 + ceil((Streitwert - 500,000) / 50,000) * 210`
| Streitwert | Simple Fee | LG 1st (3.0x) | OLG Appeal (4.0x) |
|---|---|---|---|
| EUR 500,000 | EUR 4,138 | EUR 12,414 | EUR 16,552 |
| EUR 1,000,000 | EUR 6,238 | EUR 18,714 | EUR 24,952 |
| EUR 2,000,000 | EUR 10,438 | EUR 31,314 | EUR 41,752 |
| EUR 3,000,000 | EUR 14,638 | EUR 43,914 | EUR 58,552 |
| EUR 5,000,000 | EUR 23,038 | EUR 69,114 | EUR 92,152 |
| EUR 10,000,000 | EUR 44,038 | EUR 132,114 | EUR 176,152 |
| EUR 30,000,000 | EUR 128,038 | EUR 384,114 | EUR 512,152 |
| EUR 50,000,000 | EUR 212,038 | EUR 636,114 | EUR 848,152 |
Note: GKG was updated effective 01.06.2025 -- the increment changed from EUR 198 to EUR 210 per 50k.
### German Attorney Fees (RVG)
RVG fees use the same Streitwert table (§ 13 RVG with Anlage 2 GKG) but with their own multipliers:
- Verfahrensgebuehr (procedural fee): 1.3x
- Terminsgebuehr (hearing fee): 1.2x
- Einigungsgebuehr (settlement fee): 1.0x (if applicable)
- Patent attorney (Patentanwalt) adds same fees again
Recoverable costs in Germany = court fees + 1 Rechtsanwalt (RVG) + 1 Patentanwalt (RVG).
### Side-by-Side Comparison
#### Example: Infringement action, Streitwert EUR 1,000,000
| Cost Component | UPC (pre-2026) | UPC (2026) | Germany LG |
|---|---|---|---|
| Court fees | EUR 15,000 | ~EUR 19,900 | EUR 18,714 |
| Max recoverable attorney costs | EUR 112,000 | EUR 112,000 | ~EUR 30,000* |
| **Total cost risk (loser)** | **~EUR 127,000** | **~EUR 131,900** | **~EUR 48,714** |
#### Example: Infringement action, Streitwert EUR 3,000,000
| Cost Component | UPC (pre-2026) | UPC (2026) | Germany LG |
|---|---|---|---|
| Court fees | EUR 31,000 | ~EUR 41,000 | EUR 43,914 |
| Max recoverable attorney costs | EUR 400,000 | EUR 400,000 | ~EUR 100,388** |
| **Total cost risk (loser)** | **~EUR 431,000** | **~EUR 441,000** | **~EUR 144,302** |
*German RVG: 1x RA (1.3 VG + 1.2 TG) + 1x PA (same) + Auslagenpauschale + USt on Streitwert EUR 1M
**Maiwald comparison figure for EUR 3M (1x PA + 1x RA, including court fees, based on RVG)
#### Example: Infringement action, Streitwert EUR 10,000,000
| Cost Component | UPC (pre-2026) | UPC (2026) | Germany LG |
|---|---|---|---|
| Court fees | EUR 76,000 | ~EUR 100,400 | EUR 132,114 |
| Max recoverable attorney costs | EUR 600,000 | EUR 600,000 | ~EUR 222,000 |
| **Total cost risk (loser)** | **~EUR 676,000** | **~EUR 700,400** | **~EUR 354,114** |
### Key Insight
- **Court fees**: German courts are **more expensive** than UPC for court fees alone at moderate Streitwerte (EUR 1-5M). At very high Streitwerte (EUR 10M+), German courts become more expensive still.
- **Recoverable attorney costs**: UPC is **dramatically more expensive** -- ceilings are 3-5x higher than German RVG-based costs.
- **Total cost risk**: UPC total exposure is typically **2-3x German courts** for the same Streitwert, primarily driven by the high attorney cost ceilings.
- **Territorial scope**: UPC covers multiple countries in one action, so the higher cost may be justified by the broader geographic effect compared to a single German LG action.
---
## 7. Implementation Notes for Calculator
### Data structures needed
```typescript
// Value-based fee brackets (UPC)
interface FeeBracket {
maxValue: number; // upper bound of bracket (Infinity for last)
fee: number; // EUR amount
}
// Pre-2026 brackets
const upcValueFees2025: FeeBracket[] = [
{ maxValue: 500_000, fee: 0 },
{ maxValue: 750_000, fee: 2_500 },
{ maxValue: 1_000_000, fee: 4_000 },
{ maxValue: 1_500_000, fee: 8_000 },
{ maxValue: 2_000_000, fee: 13_000 },
{ maxValue: 3_000_000, fee: 20_000 },
{ maxValue: 4_000_000, fee: 26_000 },
{ maxValue: 5_000_000, fee: 32_000 },
{ maxValue: 6_000_000, fee: 39_000 },
{ maxValue: 7_000_000, fee: 46_000 },
{ maxValue: 8_000_000, fee: 52_000 },
{ maxValue: 9_000_000, fee: 58_000 },
{ maxValue: 10_000_000, fee: 65_000 },
{ maxValue: 15_000_000, fee: 75_000 },
{ maxValue: 20_000_000, fee: 100_000 },
{ maxValue: 25_000_000, fee: 125_000 },
{ maxValue: 30_000_000, fee: 150_000 },
{ maxValue: 50_000_000, fee: 250_000 },
{ maxValue: Infinity, fee: 325_000 },
];
// Recoverable costs ceilings
const upcRecoverableCosts: FeeBracket[] = [
{ maxValue: 250_000, fee: 38_000 },
{ maxValue: 500_000, fee: 56_000 },
{ maxValue: 1_000_000, fee: 112_000 },
{ maxValue: 2_000_000, fee: 200_000 },
{ maxValue: 4_000_000, fee: 400_000 },
{ maxValue: 8_000_000, fee: 600_000 },
{ maxValue: 16_000_000, fee: 800_000 },
{ maxValue: 30_000_000, fee: 1_200_000 },
{ maxValue: 50_000_000, fee: 1_500_000 },
{ maxValue: Infinity, fee: 2_000_000 },
];
// German GKG simple fee calculation
function gkgSimpleFee(streitwert: number): number {
if (streitwert <= 500_000) {
// Use lookup table from Anlage 2 GKG
return gkgLookupTable(streitwert);
}
// Above 500k: base + 210 EUR per 50k increment
const base = 4_138; // simple fee at 500k (as of 2025-06-01)
const excess = streitwert - 500_000;
const increments = Math.ceil(excess / 50_000);
return base + increments * 210;
}
// German court fee = simple fee * multiplier
// LG 1st instance infringement: 3.0x
// OLG appeal: 4.0x
// BPatG nullity 1st: 4.5x
// BGH nullity appeal: 6.0x
```
### Fixed fees by action type (for calculator dropdown)
```typescript
type ActionType =
| 'infringement'
| 'counterclaim_infringement'
| 'non_infringement'
| 'license_compensation'
| 'determine_damages'
| 'revocation_standalone'
| 'counterclaim_revocation'
| 'provisional_measures'
| 'preserve_evidence'
| 'inspection_order'
| 'freeze_assets'
| 'protective_letter'
| 'protective_letter_extension';
interface ActionFees {
fixedFee2025: number;
fixedFee2026: number;
hasValueBasedFee: boolean;
hasValueBasedFee2026: boolean; // provisional measures gained value-based in 2026
}
```
---
## Sources
- Maiwald MAIinsight Issue No. 2, April 2025: "Court fees and recoverable costs at the UPC" (PDF, authoritative practitioner analysis)
- UPC Official Table of Court Fees: AC/05/08072022 (original schedule)
- UPC Administrative Committee amendment decision, 4 Nov 2025 (2026 changes)
- commentedupc.com/table-of-court-fees/ (complete pre-2026 table)
- Scale of Ceilings for Recoverable Costs: D-AC/10/24042023
- GKG Anlage 2 zu § 34 (German court fee table, as of 01.06.2025)
- Secerna: "Major Financial Overhaul: Significant UPC Fee Increase" (2026 data points)
- Casalonga: "Unified Patent Court: Significant Increase in Court Costs in 2026"
- Bird & Bird: "Unified Patent Court Fees increase from 1 January 2026"
- Vossius: "Costs and Cost Risk in UPC Proceedings"
- Haug Partners: "A U.S. View on the UPC -- Part 5: Of Costs and Fees"

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, Calendar,
Brain, Brain,
BarChart3, BarChart3,
Calculator,
Settings, Settings,
Menu, Menu,
X, X,
@@ -29,6 +30,7 @@ const allNavigation: NavItem[] = [
{ name: "Fristen", href: "/fristen", icon: Clock }, { name: "Fristen", href: "/fristen", icon: Clock },
{ name: "Termine", href: "/termine", icon: Calendar }, { name: "Termine", href: "/termine", icon: Calendar },
{ name: "Berichte", href: "/berichte", icon: BarChart3 }, { 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: "AI Analyse", href: "/ai/extract", icon: Brain, permission: "ai_extraction" },
{ name: "Einstellungen", href: "/einstellungen", icon: Settings, permission: "manage_settings" }, { 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; 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> { export interface PaginatedResponse<T> {
data: T[]; data: T[];
total: number; total: number;