Compare commits
4 Commits
d4092acc33
...
63b43ff7c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63b43ff7c8 | ||
|
|
f43f6e3eea | ||
|
|
850f3a62c8 | ||
|
|
08399bbb0a |
53
backend/internal/handlers/fee_calculator.go
Normal file
53
backend/internal/handlers/fee_calculator.go
Normal 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())
|
||||
}
|
||||
125
backend/internal/models/fee.go
Normal file
125
backend/internal/models/fee.go
Normal 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"`
|
||||
}
|
||||
@@ -82,6 +82,12 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
||||
billingH := handlers.NewBillingRateHandler(billingRateSvc)
|
||||
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
|
||||
mux.HandleFunc("GET /health", handleHealth(db))
|
||||
|
||||
|
||||
393
backend/internal/services/fee_calculator.go
Normal file
393
backend/internal/services/fee_calculator.go
Normal 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
|
||||
}
|
||||
421
backend/internal/services/fee_calculator_test.go
Normal file
421
backend/internal/services/fee_calculator_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
249
backend/internal/services/fee_data.go
Normal file
249
backend/internal/services/fee_data.go
Normal 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)
|
||||
)
|
||||
24
frontend/src/app/(app)/kosten/rechner/page.tsx
Normal file
24
frontend/src/app/(app)/kosten/rechner/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
323
frontend/src/components/costs/CostCalculator.tsx
Normal file
323
frontend/src/components/costs/CostCalculator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
frontend/src/components/costs/CostComparison.tsx
Normal file
83
frontend/src/components/costs/CostComparison.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
229
frontend/src/components/costs/CostSummary.tsx
Normal file
229
frontend/src/components/costs/CostSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
frontend/src/components/costs/InstanceCard.tsx
Normal file
168
frontend/src/components/costs/InstanceCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
frontend/src/components/costs/UPCCard.tsx
Normal file
115
frontend/src/components/costs/UPCCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Calendar,
|
||||
Brain,
|
||||
BarChart3,
|
||||
Calculator,
|
||||
Settings,
|
||||
Menu,
|
||||
X,
|
||||
@@ -29,6 +30,7 @@ const allNavigation: NavItem[] = [
|
||||
{ name: "Fristen", href: "/fristen", icon: Clock },
|
||||
{ name: "Termine", href: "/termine", icon: Calendar },
|
||||
{ name: "Berichte", href: "/berichte", icon: BarChart3 },
|
||||
{ name: "Kostenrechner", href: "/kosten/rechner", icon: Calculator },
|
||||
{ name: "AI Analyse", href: "/ai/extract", icon: Brain, permission: "ai_extraction" },
|
||||
{ name: "Einstellungen", href: "/einstellungen", icon: Settings, permission: "manage_settings" },
|
||||
];
|
||||
|
||||
276
frontend/src/lib/costs/calculator.ts
Normal file
276
frontend/src/lib/costs/calculator.ts
Normal 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);
|
||||
}
|
||||
239
frontend/src/lib/costs/fee-tables.ts
Normal file
239
frontend/src/lib/costs/fee-tables.ts
Normal 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;
|
||||
132
frontend/src/lib/costs/types.ts
Normal file
132
frontend/src/lib/costs/types.ts
Normal 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;
|
||||
}
|
||||
@@ -295,6 +295,44 @@ export interface ApiError {
|
||||
status: number;
|
||||
}
|
||||
|
||||
// Fee calculation types (Patentprozesskostenrechner)
|
||||
|
||||
export interface FeeCalculateRequest {
|
||||
streitwert: number;
|
||||
vat_rate: number;
|
||||
fee_version: string;
|
||||
instances: FeeInstanceInput[];
|
||||
}
|
||||
|
||||
export interface FeeInstanceInput {
|
||||
instance_type: string;
|
||||
enabled: boolean;
|
||||
num_attorneys: number;
|
||||
num_patent_attorneys: number;
|
||||
oral_hearing: boolean;
|
||||
num_clients: number;
|
||||
}
|
||||
|
||||
export interface FeeCalculateResponse {
|
||||
instances: FeeInstanceOutput[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface FeeInstanceOutput {
|
||||
instance_type: string;
|
||||
label: string;
|
||||
court_fees: number;
|
||||
attorney_fees: number;
|
||||
patent_attorney_fees: number;
|
||||
instance_total: number;
|
||||
}
|
||||
|
||||
export interface FeeScheduleInfo {
|
||||
version: string;
|
||||
label: string;
|
||||
valid_from: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
|
||||
Reference in New Issue
Block a user