ComputeBaseFee walked the bracket table indefinitely, so a Streitwert of e.g. €100M produced fees far above what German law actually permits. §34 GKG / §22(2) RVG cap the table at €30M — above that the fee stays at the 30M-row value. Surgical fix: clamp streitwert to GermanFeeStreitwertCap (30M) at the top of ComputeBaseFee. Applies to all GKG/RVG fee versions (2005, 2013, 2021, 2025); UPC value-based fees use a separate code path (lookupUPCValueFee against UPCFeeSchedule.ValueBased) and stay uncapped — UPC has its own statutory tier structure with explicit 50M and unlimited brackets. Tests: cap holds across all four versions for both GKG and RVG; values below 30M continue to scale as before; UPC remains uncapped. Spot check (GKG / RVG base, 2025 schedule): 1M EUR → 6278.00 / 5553.50 5M EUR → 23078.00 / 19553.50 30M EUR → 128078.00 / 107053.50 50M EUR → 128078.00 / 107053.50 (capped) 100M EUR → 128078.00 / 107053.50 (capped) 1B EUR → 128078.00 / 107053.50 (capped)
462 lines
14 KiB
Go
462 lines
14 KiB
Go
package calc
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
)
|
||
|
||
// Constants for attorney fee calculation.
|
||
const (
|
||
ErhoehungsFaktor = 0.3
|
||
ErhoehungsFaktorMax = 2.0
|
||
Auslagenpauschale = 20.0
|
||
)
|
||
|
||
// AttorneyBreakdown shows the detailed fee breakdown for one attorney.
|
||
type AttorneyBreakdown struct {
|
||
BaseFee float64 `json:"baseFee"`
|
||
Verfahrensgebuehr float64 `json:"verfahrensgebuehr"`
|
||
Erhoehungsgebuehr float64 `json:"erhoehungsgebuehr"`
|
||
Terminsgebuehr float64 `json:"terminsgebuehr"`
|
||
Auslagenpauschale float64 `json:"pauschale"`
|
||
NettoTotal float64 `json:"nettoTotal"`
|
||
MwSt float64 `json:"mwst"`
|
||
BruttoPerAttorney float64 `json:"bruttoPerAttorney"`
|
||
Count int `json:"count"`
|
||
BruttoTotal float64 `json:"bruttoTotal"`
|
||
}
|
||
|
||
// DEInstanceResult is the full cost breakdown for one DE court instance.
|
||
type DEInstanceResult struct {
|
||
Instance string `json:"instance"`
|
||
Label string `json:"label"`
|
||
LabelEN string `json:"labelEN"`
|
||
Enabled bool `json:"enabled"`
|
||
CourtFee float64 `json:"courtFee"`
|
||
CourtFeeBase float64 `json:"courtFeeBase"`
|
||
CourtFeeFactor float64 `json:"courtFeeFactor"`
|
||
CourtFeeBasis string `json:"courtFeeBasis"`
|
||
Attorney *AttorneyBreakdown `json:"attorney,omitempty"`
|
||
PatentAttorney *AttorneyBreakdown `json:"patentAttorney,omitempty"`
|
||
InstanceTotal float64 `json:"instanceTotal"`
|
||
}
|
||
|
||
// UPCInstanceResult is the cost breakdown for one UPC instance.
|
||
type UPCInstanceResult struct {
|
||
Instance string `json:"instance"`
|
||
Label string `json:"label"`
|
||
LabelEN string `json:"labelEN"`
|
||
Enabled bool `json:"enabled"`
|
||
FixedFee float64 `json:"fixedFee"`
|
||
ValueBasedFee float64 `json:"valueBasedFee"`
|
||
CourtFeesTotal float64 `json:"courtFeesTotal"`
|
||
CourtFeesSME float64 `json:"courtFeesSME"`
|
||
RecoverableCeiling float64 `json:"recoverableCostsCeiling"`
|
||
InstanceTotal float64 `json:"instanceTotal"`
|
||
}
|
||
|
||
// EPAInstanceResult is the cost breakdown for an EPA proceeding.
|
||
type EPAInstanceResult struct {
|
||
Instance string `json:"instance"`
|
||
Label string `json:"label"`
|
||
LabelEN string `json:"labelEN"`
|
||
Enabled bool `json:"enabled"`
|
||
Fee float64 `json:"fee"`
|
||
IsSME bool `json:"isSME"`
|
||
InstanceTotal float64 `json:"instanceTotal"`
|
||
}
|
||
|
||
// InstanceInput describes one instance's configuration from the client.
|
||
type InstanceInput struct {
|
||
Enabled bool `json:"enabled"`
|
||
FeeVersion string `json:"feeVersion,omitempty"`
|
||
NumAttorneys int `json:"numAttorneys,omitempty"`
|
||
NumPatentAttorneys int `json:"numPatentAttorneys,omitempty"`
|
||
NumClients int `json:"numClients,omitempty"`
|
||
OralHearing bool `json:"oralHearing,omitempty"`
|
||
IsSME bool `json:"isSME,omitempty"`
|
||
IncludeRevocation bool `json:"includeRevocation,omitempty"`
|
||
}
|
||
|
||
// CostRequest is the JSON request body for the Kostenrechner API.
|
||
type CostRequest struct {
|
||
Streitwert float64 `json:"streitwert"`
|
||
VATRate float64 `json:"vatRate"`
|
||
Instances map[string]InstanceInput `json:"instances"`
|
||
}
|
||
|
||
// CostResponse is the JSON response for the Kostenrechner API.
|
||
type CostResponse struct {
|
||
DEResults []DEInstanceResult `json:"deResults"`
|
||
UPCResults []UPCInstanceResult `json:"upcResults"`
|
||
EPAResults []EPAInstanceResult `json:"epaResults"`
|
||
Totals CostTotals `json:"totals"`
|
||
}
|
||
|
||
// CostTotals summarizes the total costs across all instances.
|
||
type CostTotals struct {
|
||
CourtFees float64 `json:"courtFees"`
|
||
AttorneyFees float64 `json:"attorneyFees"`
|
||
PatentAttorneyFees float64 `json:"patentAttorneyFees"`
|
||
EPAFees float64 `json:"epaFees"`
|
||
GrandTotal float64 `json:"grandTotal"`
|
||
}
|
||
|
||
// resolveFeeVersion resolves aliases and returns the bracket list.
|
||
func resolveFeeVersion(version string) ([]FeeBracket, error) {
|
||
if alias, ok := FeeScheduleAliases[version]; ok {
|
||
version = alias
|
||
}
|
||
schedule, ok := FeeSchedules[version]
|
||
if !ok {
|
||
return nil, fmt.Errorf("unknown fee version: %s", version)
|
||
}
|
||
return schedule.Brackets, nil
|
||
}
|
||
|
||
// GermanFeeStreitwertCap is the §34 GKG / §22(2) RVG hard ceiling: above
|
||
// 30M EUR Streitwert, German court and attorney fees stay at the 30M-row
|
||
// value. UPC value-based fees use a separate code path (lookupUPCValueFee)
|
||
// and are not affected by this cap.
|
||
const GermanFeeStreitwertCap = 30_000_000.0
|
||
|
||
// ComputeBaseFee calculates the 1.0x base fee using the step-based accumulator.
|
||
// isRVG=true for attorney fees (RVG), false for court fees (GKG).
|
||
func ComputeBaseFee(streitwert float64, isRVG bool, version string) (float64, error) {
|
||
brackets, err := resolveFeeVersion(version)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
if streitwert > GermanFeeStreitwertCap {
|
||
streitwert = GermanFeeStreitwertCap
|
||
}
|
||
|
||
remaining := streitwert
|
||
fee := 0.0
|
||
lowerBound := 0.0
|
||
|
||
for _, b := range brackets {
|
||
increment := b.GKGIncrement
|
||
if isRVG {
|
||
increment = b.RVGIncrement
|
||
}
|
||
|
||
bracketSize := b.UpperBound - lowerBound
|
||
if math.IsInf(b.UpperBound, 1) {
|
||
bracketSize = remaining
|
||
}
|
||
portion := math.Min(remaining, bracketSize)
|
||
if portion <= 0 {
|
||
break
|
||
}
|
||
|
||
if lowerBound == 0 {
|
||
// First bracket: minimum fee = one increment
|
||
fee += increment
|
||
stepsAfterFirst := math.Max(0, math.Ceil((portion-b.StepSize)/b.StepSize))
|
||
fee += stepsAfterFirst * increment
|
||
} else {
|
||
steps := math.Ceil(portion / b.StepSize)
|
||
fee += steps * increment
|
||
}
|
||
|
||
remaining -= portion
|
||
lowerBound = b.UpperBound
|
||
if remaining <= 0 {
|
||
break
|
||
}
|
||
}
|
||
return fee, nil
|
||
}
|
||
|
||
// computeAttorneyFees calculates fees for a single attorney.
|
||
func computeAttorneyFees(streitwert float64, version string, vgFactor, tgFactor float64, oralHearing bool, numClients, count int, vatRate float64) (*AttorneyBreakdown, error) {
|
||
if count <= 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
baseFee, err := ComputeBaseFee(streitwert, true, version)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
vg := vgFactor * baseFee
|
||
erhoehung := 0.0
|
||
if numClients > 1 {
|
||
factor := math.Min(float64(numClients-1)*ErhoehungsFaktor, ErhoehungsFaktorMax)
|
||
erhoehung = factor * baseFee
|
||
}
|
||
tg := 0.0
|
||
if oralHearing {
|
||
tg = tgFactor * baseFee
|
||
}
|
||
|
||
netto := vg + erhoehung + tg + Auslagenpauschale
|
||
mwst := netto * vatRate
|
||
brutto := netto + mwst
|
||
|
||
return &AttorneyBreakdown{
|
||
BaseFee: round2(baseFee),
|
||
Verfahrensgebuehr: round2(vg),
|
||
Erhoehungsgebuehr: round2(erhoehung),
|
||
Terminsgebuehr: round2(tg),
|
||
Auslagenpauschale: Auslagenpauschale,
|
||
NettoTotal: round2(netto),
|
||
MwSt: round2(mwst),
|
||
BruttoPerAttorney: round2(brutto),
|
||
Count: count,
|
||
BruttoTotal: round2(brutto * float64(count)),
|
||
}, nil
|
||
}
|
||
|
||
// ComputeDEInstance calculates all costs for one DE court instance.
|
||
func ComputeDEInstance(streitwert float64, input InstanceInput, meta InstanceMeta, vatRate float64) (*DEInstanceResult, error) {
|
||
if !input.Enabled {
|
||
return &DEInstanceResult{
|
||
Instance: meta.Key,
|
||
Label: meta.Label,
|
||
LabelEN: meta.LabelEN,
|
||
Enabled: false,
|
||
}, nil
|
||
}
|
||
|
||
version := input.FeeVersion
|
||
if version == "" {
|
||
version = "Aktuell"
|
||
}
|
||
|
||
// Court fees: factor × base fee (GKG or PatKostG)
|
||
baseFee, err := ComputeBaseFee(streitwert, false, version)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
courtFee := round2(meta.CourtFeeFactor * baseFee)
|
||
|
||
// Attorney fees
|
||
ra, err := computeAttorneyFees(streitwert, version, meta.RAVGFactor, meta.RATGFactor, input.OralHearing, input.NumClients, input.NumAttorneys, vatRate)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Patent attorney fees
|
||
var pa *AttorneyBreakdown
|
||
if meta.HasPatentAttorneys {
|
||
pa, err = computeAttorneyFees(streitwert, version, meta.PAVGFactor, meta.PATGFactor, input.OralHearing, input.NumClients, input.NumPatentAttorneys, vatRate)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
raTotal := 0.0
|
||
if ra != nil {
|
||
raTotal = ra.BruttoTotal
|
||
}
|
||
paTotal := 0.0
|
||
if pa != nil {
|
||
paTotal = pa.BruttoTotal
|
||
}
|
||
|
||
return &DEInstanceResult{
|
||
Instance: meta.Key,
|
||
Label: meta.Label,
|
||
LabelEN: meta.LabelEN,
|
||
Enabled: true,
|
||
CourtFee: courtFee,
|
||
CourtFeeBase: round2(baseFee),
|
||
CourtFeeFactor: meta.CourtFeeFactor,
|
||
CourtFeeBasis: meta.FeeBasis,
|
||
Attorney: ra,
|
||
PatentAttorney: pa,
|
||
InstanceTotal: round2(courtFee + raTotal + paTotal),
|
||
}, nil
|
||
}
|
||
|
||
// lookupUPCValueFee finds the value-based fee for a given Streitwert.
|
||
func lookupUPCValueFee(streitwert float64, brackets []UPCFeeBracket) float64 {
|
||
for _, b := range brackets {
|
||
if b.MaxValue == nil || streitwert <= *b.MaxValue {
|
||
return b.Fee
|
||
}
|
||
}
|
||
return brackets[len(brackets)-1].Fee
|
||
}
|
||
|
||
// lookupRecoverableCosts finds the recoverable cost ceiling for a given Streitwert.
|
||
func lookupRecoverableCosts(streitwert float64, table []UPCRecoverableCost) float64 {
|
||
for _, e := range table {
|
||
if e.MaxValue == nil || streitwert <= *e.MaxValue {
|
||
return e.Ceiling
|
||
}
|
||
}
|
||
return table[len(table)-1].Ceiling
|
||
}
|
||
|
||
// ComputeUPCInstance calculates UPC costs for one instance.
|
||
func ComputeUPCInstance(streitwert float64, input InstanceInput, instanceKey string) (*UPCInstanceResult, error) {
|
||
label := "UPC (1. Instanz)"
|
||
labelEN := "UPC (First Instance)"
|
||
if instanceKey == "UPC_APPEAL" {
|
||
label = "UPC (Berufung)"
|
||
labelEN = "UPC (Appeal)"
|
||
}
|
||
|
||
if !input.Enabled {
|
||
return &UPCInstanceResult{
|
||
Instance: instanceKey,
|
||
Label: label,
|
||
LabelEN: labelEN,
|
||
Enabled: false,
|
||
}, nil
|
||
}
|
||
|
||
version := input.FeeVersion
|
||
if version == "" {
|
||
version = "2026"
|
||
}
|
||
|
||
feeData, ok := UPCFeeSchedules[version]
|
||
if !ok {
|
||
return nil, fmt.Errorf("unknown UPC fee version: %s", version)
|
||
}
|
||
|
||
fixedFee := feeData.FixedInfringement
|
||
if input.IncludeRevocation {
|
||
fixedFee += feeData.FixedRevocation
|
||
}
|
||
|
||
valueBasedFee := lookupUPCValueFee(streitwert, feeData.ValueBased)
|
||
courtTotal := fixedFee + valueBasedFee
|
||
courtSME := math.Round(courtTotal * (1 - feeData.SMEReduction))
|
||
recoverableCeiling := lookupRecoverableCosts(streitwert, feeData.RecoverableCosts)
|
||
|
||
effectiveCourtFee := courtTotal
|
||
if input.IsSME {
|
||
effectiveCourtFee = courtSME
|
||
}
|
||
|
||
// InstanceTotal is the user's own outlay for this instance — court fee
|
||
// only. RecoverableCeiling is the OPPOSING side's R.152 cost cap (a
|
||
// worst-case loss-of-suit liability) and is exposed as its own line
|
||
// item so the user sees worst-case exposure separately, not folded
|
||
// into the GESAMTKOSTEN they themselves owe.
|
||
return &UPCInstanceResult{
|
||
Instance: instanceKey,
|
||
Label: label,
|
||
LabelEN: labelEN,
|
||
Enabled: true,
|
||
FixedFee: fixedFee,
|
||
ValueBasedFee: valueBasedFee,
|
||
CourtFeesTotal: courtTotal,
|
||
CourtFeesSME: courtSME,
|
||
RecoverableCeiling: recoverableCeiling,
|
||
InstanceTotal: effectiveCourtFee,
|
||
}, nil
|
||
}
|
||
|
||
// ComputeEPAInstance returns the cost for an EPA proceeding.
|
||
func ComputeEPAInstance(input InstanceInput, instanceKey string) (*EPAInstanceResult, error) {
|
||
epa, ok := EPAFees[instanceKey]
|
||
if !ok {
|
||
return nil, fmt.Errorf("unknown EPA instance: %s", instanceKey)
|
||
}
|
||
|
||
if !input.Enabled {
|
||
return &EPAInstanceResult{
|
||
Instance: instanceKey,
|
||
Label: epa.Label,
|
||
LabelEN: epa.LabelEN,
|
||
Enabled: false,
|
||
}, nil
|
||
}
|
||
|
||
fee := epa.Fee
|
||
if input.IsSME && epa.SMEFee > 0 {
|
||
fee = epa.SMEFee
|
||
}
|
||
|
||
return &EPAInstanceResult{
|
||
Instance: instanceKey,
|
||
Label: epa.Label,
|
||
LabelEN: epa.LabelEN,
|
||
Enabled: true,
|
||
Fee: fee,
|
||
IsSME: input.IsSME,
|
||
InstanceTotal: fee,
|
||
}, nil
|
||
}
|
||
|
||
// Calculate processes a full CostRequest and returns a CostResponse.
|
||
func Calculate(req CostRequest) (*CostResponse, error) {
|
||
resp := &CostResponse{}
|
||
var totals CostTotals
|
||
|
||
// DE instances
|
||
allDE := AllDEInstances()
|
||
for _, meta := range allDE {
|
||
input, ok := req.Instances[meta.Key]
|
||
if !ok {
|
||
input = InstanceInput{Enabled: false}
|
||
}
|
||
result, err := ComputeDEInstance(req.Streitwert, input, meta, req.VATRate)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
resp.DEResults = append(resp.DEResults, *result)
|
||
if result.Enabled {
|
||
totals.CourtFees += result.CourtFee
|
||
if result.Attorney != nil {
|
||
totals.AttorneyFees += result.Attorney.BruttoTotal
|
||
}
|
||
if result.PatentAttorney != nil {
|
||
totals.PatentAttorneyFees += result.PatentAttorney.BruttoTotal
|
||
}
|
||
}
|
||
}
|
||
|
||
// UPC instances
|
||
for _, key := range []string{"UPC_FIRST", "UPC_APPEAL"} {
|
||
input, ok := req.Instances[key]
|
||
if !ok {
|
||
input = InstanceInput{Enabled: false}
|
||
}
|
||
result, err := ComputeUPCInstance(req.Streitwert, input, key)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
resp.UPCResults = append(resp.UPCResults, *result)
|
||
if result.Enabled {
|
||
totals.CourtFees += result.InstanceTotal
|
||
}
|
||
}
|
||
|
||
// EPA instances
|
||
for _, key := range []string{"EPA_OPPOSITION", "EPA_APPEAL"} {
|
||
input, ok := req.Instances[key]
|
||
if !ok {
|
||
input = InstanceInput{Enabled: false}
|
||
}
|
||
result, err := ComputeEPAInstance(input, key)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
resp.EPAResults = append(resp.EPAResults, *result)
|
||
if result.Enabled {
|
||
totals.EPAFees += result.InstanceTotal
|
||
}
|
||
}
|
||
|
||
totals.GrandTotal = round2(totals.CourtFees + totals.AttorneyFees + totals.PatentAttorneyFees + totals.EPAFees)
|
||
totals.CourtFees = round2(totals.CourtFees)
|
||
totals.AttorneyFees = round2(totals.AttorneyFees)
|
||
totals.PatentAttorneyFees = round2(totals.PatentAttorneyFees)
|
||
totals.EPAFees = round2(totals.EPAFees)
|
||
resp.Totals = totals
|
||
|
||
return resp, nil
|
||
}
|
||
|
||
func round2(v float64) float64 {
|
||
return math.Round(v*100) / 100
|
||
}
|