Compare commits

..

4 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
17 changed files with 2876 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)
)

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;