feat: add Patentprozesskostenrechner fee calculation engine + API
Pure Go implementation of patent litigation cost calculator with: - Step-based GKG/RVG fee accumulator across 4 historical schedules (2005/2013/2021/2025 + Aktuell alias) - Instance multiplier tables for 8 court types (LG, OLG, BGH NZB/Rev, BPatG, BGH Null, DPMA, BPatG Canc) - Full attorney fee calculation (VG, TG, Erhöhungsgebühr Nr. 1008 VV RVG, Auslagenpauschale) - Prozesskostensicherheit computation - UPC fee data (pre-2026 and 2026 schedules with value-based brackets, recoverable costs ceilings) - Public API: POST /api/fees/calculate, GET /api/fees/schedules (no auth required) - 22 unit tests covering all calculation paths Fixes 3 Excel bugs: - Bug 1: Prozesskostensicherheit VAT formula (subtract → add) - Bug 2: Security for costs uses GKG base for court fee, not RVG - Bug 3: Expert fees included in BPatG instance total
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user