From 850f3a62c8d6231e0cfde0604284c38f02bb4e9e Mon Sep 17 00:00:00 2001 From: m Date: Tue, 31 Mar 2026 17:43:17 +0200 Subject: [PATCH] feat: add Patentprozesskostenrechner fee calculation engine + API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure Go implementation of patent litigation cost calculator with: - Step-based GKG/RVG fee accumulator across 4 historical schedules (2005/2013/2021/2025 + Aktuell alias) - Instance multiplier tables for 8 court types (LG, OLG, BGH NZB/Rev, BPatG, BGH Null, DPMA, BPatG Canc) - Full attorney fee calculation (VG, TG, Erhöhungsgebühr Nr. 1008 VV RVG, Auslagenpauschale) - Prozesskostensicherheit computation - UPC fee data (pre-2026 and 2026 schedules with value-based brackets, recoverable costs ceilings) - Public API: POST /api/fees/calculate, GET /api/fees/schedules (no auth required) - 22 unit tests covering all calculation paths Fixes 3 Excel bugs: - Bug 1: Prozesskostensicherheit VAT formula (subtract → add) - Bug 2: Security for costs uses GKG base for court fee, not RVG - Bug 3: Expert fees included in BPatG instance total --- backend/internal/handlers/fee_calculator.go | 53 +++ backend/internal/models/fee.go | 125 ++++++ backend/internal/router/router.go | 6 + backend/internal/services/fee_calculator.go | 393 ++++++++++++++++ .../internal/services/fee_calculator_test.go | 421 ++++++++++++++++++ backend/internal/services/fee_data.go | 249 +++++++++++ 6 files changed, 1247 insertions(+) create mode 100644 backend/internal/handlers/fee_calculator.go create mode 100644 backend/internal/models/fee.go create mode 100644 backend/internal/services/fee_calculator.go create mode 100644 backend/internal/services/fee_calculator_test.go create mode 100644 backend/internal/services/fee_data.go diff --git a/backend/internal/handlers/fee_calculator.go b/backend/internal/handlers/fee_calculator.go new file mode 100644 index 0000000..99bb960 --- /dev/null +++ b/backend/internal/handlers/fee_calculator.go @@ -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()) +} diff --git a/backend/internal/models/fee.go b/backend/internal/models/fee.go new file mode 100644 index 0000000..70d6a00 --- /dev/null +++ b/backend/internal/models/fee.go @@ -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"` +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index f1e5b7f..ed315c0 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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)) diff --git a/backend/internal/services/fee_calculator.go b/backend/internal/services/fee_calculator.go new file mode 100644 index 0000000..175563b --- /dev/null +++ b/backend/internal/services/fee_calculator.go @@ -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 +} diff --git a/backend/internal/services/fee_calculator_test.go b/backend/internal/services/fee_calculator_test.go new file mode 100644 index 0000000..f703469 --- /dev/null +++ b/backend/internal/services/fee_calculator_test.go @@ -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) + } +} diff --git a/backend/internal/services/fee_data.go b/backend/internal/services/fee_data.go new file mode 100644 index 0000000..d7528aa --- /dev/null +++ b/backend/internal/services/fee_data.go @@ -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) +)