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:
m
2026-03-31 17:43:17 +02:00
parent d4092acc33
commit 850f3a62c8
6 changed files with 1247 additions and 0 deletions

View 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
}