Files
paliad/internal/calc/fees.go
m bd621664cf feat: implement Prozesskostenrechner (patent litigation cost calculator)
Go calculation engine (internal/calc/):
- GKG/RVG step-based fee computation with 4 schedule versions (2005-2025)
- DE court instances: LG, OLG, BGH NZB/Rev, BPatG, BGH Nullity
- UPC fees: fixed + value-based with SME reduction (pre-2026 and 2026)
- EPA proceedings: Opposition and Appeal fixed fees
- Attorney + patent attorney fee breakdown with Erhöhung, MwSt
- 11 unit tests covering all calculation paths

Frontend (Bun/TSX):
- SSR page shell with two-column layout (inputs + sticky results)
- Streitwert slider + presets, VAT selector, instance cards with details
- Client JS: form state management, API calls, result rendering
- Print-friendly layout

API: POST /api/tools/kostenrechner (protected, JSON)
Route: GET /tools/kostenrechner (protected page)
Navigation: Added tool links to header
2026-04-14 17:25:38 +02:00

447 lines
13 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
}
// 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
}
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
}
return &UPCInstanceResult{
Instance: instanceKey,
Label: label,
LabelEN: labelEN,
Enabled: true,
FixedFee: fixedFee,
ValueBasedFee: valueBasedFee,
CourtFeesTotal: courtTotal,
CourtFeesSME: courtSME,
RecoverableCeiling: recoverableCeiling,
InstanceTotal: effectiveCourtFee + recoverableCeiling,
}, 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
}