Compare commits
3 Commits
mai/ritchi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63b43ff7c8 | ||
|
|
f43f6e3eea | ||
|
|
850f3a62c8 |
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)
|
billingH := handlers.NewBillingRateHandler(billingRateSvc)
|
||||||
templateH := handlers.NewTemplateHandler(templateSvc, caseSvc, partySvc, deadlineSvc, tenantSvc)
|
templateH := handlers.NewTemplateHandler(templateSvc, caseSvc, partySvc, deadlineSvc, tenantSvc)
|
||||||
|
|
||||||
|
// Fee calculator (public — no auth required, pure computation)
|
||||||
|
feeCalc := services.NewFeeCalculator()
|
||||||
|
feeCalcH := handlers.NewFeeCalculatorHandler(feeCalc)
|
||||||
|
mux.HandleFunc("POST /api/fees/calculate", feeCalcH.Calculate)
|
||||||
|
mux.HandleFunc("GET /api/fees/schedules", feeCalcH.Schedules)
|
||||||
|
|
||||||
// Public routes
|
// Public routes
|
||||||
mux.HandleFunc("GET /health", handleHealth(db))
|
mux.HandleFunc("GET /health", handleHealth(db))
|
||||||
|
|
||||||
|
|||||||
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)
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user