Files
paliad/internal/calc/fees.go
m 0e1d4869fb fix(t-paliad-130): cap GKG/RVG Streitwert at €30M (§34 GKG / §22(2) RVG)
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)
2026-05-04 20:58:08 +02:00

462 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}