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:
421
backend/internal/services/fee_calculator_test.go
Normal file
421
backend/internal/services/fee_calculator_test.go
Normal file
@@ -0,0 +1,421 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
||||
)
|
||||
|
||||
func TestCalculateBaseFee_GKG_2025(t *testing.T) {
|
||||
fc := NewFeeCalculator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
streitwert float64
|
||||
want float64
|
||||
}{
|
||||
{"minimum (500 EUR)", 500, 40},
|
||||
{"1000 EUR", 1000, 61}, // 40 + ceil(500/500)*21 = 40+21
|
||||
{"2000 EUR", 2000, 103}, // 40 + ceil(1500/500)*21 = 40+63
|
||||
{"500k EUR", 500000, 4138}, // verified against GKG Anlage 2
|
||||
{"1M EUR", 1000000, 6238}, // 4138 + ceil(500k/50k)*210 = 4138+2100
|
||||
{"3M EUR", 3000000, 14638}, // 4138 + ceil(2.5M/50k)*210 = 4138+10500
|
||||
{"5M EUR", 5000000, 23038}, // 4138 + ceil(4.5M/50k)*210 = 4138+18900
|
||||
{"10M EUR", 10000000, 44038}, // 4138 + ceil(9.5M/50k)*210 = 4138+39900
|
||||
{"30M EUR", 30000000, 128038}, // 4138 + ceil(29.5M/50k)*210 = 4138+123900
|
||||
{"50M EUR", 50000000, 212038}, // 4138 + ceil(49.5M/50k)*210 = 4138+207900
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := fc.CalculateBaseFee(tt.streitwert, "2025", "GKG")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if math.Abs(got-tt.want) > 0.01 {
|
||||
t.Errorf("CalculateBaseFee(%v, 2025, GKG) = %v, want %v", tt.streitwert, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateBaseFee_RVG_2025(t *testing.T) {
|
||||
fc := NewFeeCalculator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
streitwert float64
|
||||
want float64
|
||||
}{
|
||||
{"minimum (500 EUR)", 500, 51.5},
|
||||
{"1000 EUR", 1000, 93}, // 51.5 + ceil(500/500)*41.5 = 51.5+41.5
|
||||
{"2000 EUR", 2000, 176}, // 51.5 + ceil(1500/500)*41.5 = 51.5+124.5
|
||||
{"500k EUR", 500000, 3752}, // computed via brackets
|
||||
{"1M EUR", 1000000, 5502}, // 3752 + ceil(500k/50k)*175 = 3752+1750
|
||||
{"3M EUR", 3000000, 12502}, // 3752 + ceil(2.5M/50k)*175 = 3752+8750
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := fc.CalculateBaseFee(tt.streitwert, "2025", "RVG")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if math.Abs(got-tt.want) > 0.01 {
|
||||
t.Errorf("CalculateBaseFee(%v, 2025, RVG) = %v, want %v", tt.streitwert, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateBaseFee_GKG_2013(t *testing.T) {
|
||||
fc := NewFeeCalculator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
streitwert float64
|
||||
want float64
|
||||
}{
|
||||
{"minimum (500 EUR)", 500, 35},
|
||||
{"1000 EUR", 1000, 53}, // 35 + ceil(500/500)*18 = 35+18
|
||||
{"1500 EUR", 1500, 71}, // 35 + ceil(1000/500)*18 = 35+36
|
||||
{"2000 EUR", 2000, 89}, // 35 + ceil(1500/500)*18 = 35+54
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := fc.CalculateBaseFee(tt.streitwert, "2013", "GKG")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if math.Abs(got-tt.want) > 0.01 {
|
||||
t.Errorf("CalculateBaseFee(%v, 2013, GKG) = %v, want %v", tt.streitwert, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateBaseFee_Aktuell_IsAlias(t *testing.T) {
|
||||
fc := NewFeeCalculator()
|
||||
|
||||
aktuell, err := fc.CalculateBaseFee(1000000, "Aktuell", "GKG")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
v2025, err := fc.CalculateBaseFee(1000000, "2025", "GKG")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if aktuell != v2025 {
|
||||
t.Errorf("Aktuell (%v) should equal 2025 (%v)", aktuell, v2025)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateBaseFee_InvalidSchedule(t *testing.T) {
|
||||
fc := NewFeeCalculator()
|
||||
_, err := fc.CalculateBaseFee(1000, "1999", "GKG")
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown schedule")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateCourtFees(t *testing.T) {
|
||||
fc := NewFeeCalculator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
streitwert float64
|
||||
instanceType string
|
||||
version string
|
||||
wantCourtFee float64
|
||||
wantSource string
|
||||
}{
|
||||
{"LG 1M (3.0x GKG)", 1000000, "LG", "2025", 18714, "GKG"},
|
||||
{"LG 3M (3.0x GKG)", 3000000, "LG", "2025", 43914, "GKG"},
|
||||
{"OLG 3M (4.0x GKG)", 3000000, "OLG", "2025", 58552, "GKG"},
|
||||
{"BPatG 1M (4.5x PatKostG)", 1000000, "BPatG", "2025", 28071, "PatKostG"},
|
||||
{"DPMA (fixed 300)", 1000000, "DPMA", "2025", 300, "fixed"},
|
||||
{"BPatG_Canc (fixed 500)", 1000000, "BPatG_Canc", "2025", 500, "fixed"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, _, courtFee, source, err := fc.CalculateCourtFees(tt.streitwert, tt.instanceType, tt.version)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if math.Abs(courtFee-tt.wantCourtFee) > 0.01 {
|
||||
t.Errorf("court fee = %v, want %v", courtFee, tt.wantCourtFee)
|
||||
}
|
||||
if source != tt.wantSource {
|
||||
t.Errorf("source = %v, want %v", source, tt.wantSource)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAttorneyFees(t *testing.T) {
|
||||
fc := NewFeeCalculator()
|
||||
|
||||
// 1 RA at LG, 1M Streitwert, 1 client, oral hearing, no VAT
|
||||
bd, err := fc.CalculateAttorneyFees(1000000, "2025", 1.3, 1.2, 1, 1, true, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if bd == nil {
|
||||
t.Fatal("expected non-nil breakdown")
|
||||
}
|
||||
|
||||
// RVG base at 1M = 5502
|
||||
if math.Abs(bd.BaseFee-5502) > 0.01 {
|
||||
t.Errorf("base fee = %v, want 5502", bd.BaseFee)
|
||||
}
|
||||
// VG = 1.3 * 5502 = 7152.6
|
||||
if math.Abs(bd.VGFee-7152.6) > 0.01 {
|
||||
t.Errorf("VG fee = %v, want 7152.6", bd.VGFee)
|
||||
}
|
||||
// No increase (1 client)
|
||||
if bd.IncreaseFee != 0 {
|
||||
t.Errorf("increase fee = %v, want 0", bd.IncreaseFee)
|
||||
}
|
||||
// TG = 1.2 * 5502 = 6602.4
|
||||
if math.Abs(bd.TGFee-6602.4) > 0.01 {
|
||||
t.Errorf("TG fee = %v, want 6602.4", bd.TGFee)
|
||||
}
|
||||
// Subtotal net = 7152.6 + 0 + 6602.4 + 20 = 13775
|
||||
if math.Abs(bd.SubtotalNet-13775) > 0.01 {
|
||||
t.Errorf("subtotal net = %v, want 13775", bd.SubtotalNet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAttorneyFees_WithVAT(t *testing.T) {
|
||||
fc := NewFeeCalculator()
|
||||
|
||||
bd, err := fc.CalculateAttorneyFees(1000000, "2025", 1.3, 1.2, 1, 1, true, 0.19)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Net = 13775, VAT = 13775 * 0.19 = 2617.25
|
||||
wantVAT := 13775 * 0.19
|
||||
if math.Abs(bd.VAT-wantVAT) > 0.01 {
|
||||
t.Errorf("VAT = %v, want %v", bd.VAT, wantVAT)
|
||||
}
|
||||
wantGross := 13775 + wantVAT
|
||||
if math.Abs(bd.SubtotalGross-wantGross) > 0.01 {
|
||||
t.Errorf("subtotal gross = %v, want %v", bd.SubtotalGross, wantGross)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAttorneyFees_MultipleClients(t *testing.T) {
|
||||
fc := NewFeeCalculator()
|
||||
|
||||
// 3 clients → increase factor = min((3-1)*0.3, 2.0) = 0.6
|
||||
bd, err := fc.CalculateAttorneyFees(1000000, "2025", 1.3, 1.2, 1, 3, true, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
wantIncrease := 0.6 * 5502 // 3301.2
|
||||
if math.Abs(bd.IncreaseFee-wantIncrease) > 0.01 {
|
||||
t.Errorf("increase fee = %v, want %v", bd.IncreaseFee, wantIncrease)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAttorneyFees_IncreaseCapped(t *testing.T) {
|
||||
fc := NewFeeCalculator()
|
||||
|
||||
// 10 clients → factor = min((10-1)*0.3, 2.0) = min(2.7, 2.0) = 2.0
|
||||
bd, err := fc.CalculateAttorneyFees(1000000, "2025", 1.3, 1.2, 1, 10, true, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
wantIncrease := 2.0 * 5502 // 11004
|
||||
if math.Abs(bd.IncreaseFee-wantIncrease) > 0.01 {
|
||||
t.Errorf("increase fee = %v, want %v (should be capped at 2.0x)", bd.IncreaseFee, wantIncrease)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAttorneyFees_NoHearing(t *testing.T) {
|
||||
fc := NewFeeCalculator()
|
||||
|
||||
bd, err := fc.CalculateAttorneyFees(1000000, "2025", 1.3, 1.2, 1, 1, false, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if bd.TGFee != 0 {
|
||||
t.Errorf("TG fee = %v, want 0 (no hearing)", bd.TGFee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAttorneyFees_ZeroAttorneys(t *testing.T) {
|
||||
fc := NewFeeCalculator()
|
||||
|
||||
bd, err := fc.CalculateAttorneyFees(1000000, "2025", 1.3, 1.2, 0, 1, true, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if bd != nil {
|
||||
t.Error("expected nil breakdown for 0 attorneys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateInstanceTotal_ExpertFees(t *testing.T) {
|
||||
fc := NewFeeCalculator()
|
||||
|
||||
// Bug 3 fix: expert fees must be included in court subtotal
|
||||
inst := models.InstanceInput{
|
||||
Type: models.InstanceBPatG,
|
||||
Enabled: true,
|
||||
FeeVersion: "2025",
|
||||
NumAttorneys: 1,
|
||||
NumPatentAttorneys: 1,
|
||||
NumClients: 1,
|
||||
OralHearing: true,
|
||||
ExpertFees: 10000,
|
||||
}
|
||||
|
||||
result, err := fc.CalculateInstanceTotal(1000000, inst, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Court subtotal should include expert fees
|
||||
wantCourtSubtotal := result.CourtFee + 10000
|
||||
if math.Abs(result.CourtSubtotal-wantCourtSubtotal) > 0.01 {
|
||||
t.Errorf("court subtotal = %v, want %v (should include expert fees)", result.CourtSubtotal, wantCourtSubtotal)
|
||||
}
|
||||
|
||||
// Instance total should include expert fees
|
||||
if result.InstanceTotal < result.CourtFee+10000 {
|
||||
t.Errorf("instance total %v should include expert fees (court fee %v + expert 10000)", result.InstanceTotal, result.CourtFee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateSecurityForCosts_BugFixes(t *testing.T) {
|
||||
fc := NewFeeCalculator()
|
||||
|
||||
// Bug 1: VAT should be added (1 + VAT), not subtracted
|
||||
sec, err := fc.CalculateSecurityForCosts(1000000, "2025", 1, 0.19)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if sec.TotalGross < sec.SubtotalNet {
|
||||
t.Errorf("Bug 1: total %v should be > subtotal %v (VAT should add, not subtract)", sec.TotalGross, sec.SubtotalNet)
|
||||
}
|
||||
wantTotal := sec.SubtotalNet * 1.19
|
||||
if math.Abs(sec.TotalGross-wantTotal) > 0.01 {
|
||||
t.Errorf("Bug 1: total = %v, want %v", sec.TotalGross, wantTotal)
|
||||
}
|
||||
|
||||
// Bug 2: verify inst2 uses GKG base, not RVG base
|
||||
// At 1M: GKG base = 6238, RVG base = 5502
|
||||
// inst2 includes "4.0x court" = 4.0 * GKG_base = 24952
|
||||
// If it incorrectly used RVG: 4.0 * 5502 = 22008
|
||||
rvgBase := 5502.0
|
||||
gkgBase := 6238.0
|
||||
expectedInst2 := 2.8*rvgBase + 2.8*rvgBase + 4.0*gkgBase + 5000
|
||||
if math.Abs(sec.Instance2-expectedInst2) > 0.01 {
|
||||
t.Errorf("Bug 2: instance2 = %v, want %v (should use GKG base for court fee)", sec.Instance2, expectedInst2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateSecurityForCosts_ZeroVAT(t *testing.T) {
|
||||
fc := NewFeeCalculator()
|
||||
|
||||
sec, err := fc.CalculateSecurityForCosts(1000000, "2025", 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// With 0% VAT, total should equal subtotal (bug is invisible)
|
||||
if sec.TotalGross != sec.SubtotalNet {
|
||||
t.Errorf("with 0%% VAT: total %v should equal subtotal %v", sec.TotalGross, sec.SubtotalNet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateFullLitigation_Infringement(t *testing.T) {
|
||||
fc := NewFeeCalculator()
|
||||
|
||||
req := models.FeeCalculateRequest{
|
||||
Streitwert: 1000000,
|
||||
VATRate: 0,
|
||||
ProceedingPath: models.PathInfringement,
|
||||
Instances: []models.InstanceInput{
|
||||
{Type: models.InstanceLG, Enabled: true, FeeVersion: "2025", NumAttorneys: 1, NumPatentAttorneys: 1, NumClients: 1, OralHearing: true},
|
||||
{Type: models.InstanceOLG, Enabled: true, FeeVersion: "2025", NumAttorneys: 1, NumPatentAttorneys: 1, NumClients: 1, OralHearing: true},
|
||||
{Type: models.InstanceBGHNZB, Enabled: true, FeeVersion: "2025", NumAttorneys: 1, NumPatentAttorneys: 1, NumClients: 1, OralHearing: true},
|
||||
{Type: models.InstanceBGHRev, Enabled: false, FeeVersion: "2025", NumAttorneys: 1, NumPatentAttorneys: 1, NumClients: 1, OralHearing: true},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := fc.CalculateFullLitigation(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// 3 enabled instances
|
||||
if len(resp.Instances) != 3 {
|
||||
t.Errorf("got %d instances, want 3", len(resp.Instances))
|
||||
}
|
||||
|
||||
// Should have at least "Gesamtkosten bei Nichtzulassung"
|
||||
found := false
|
||||
for _, total := range resp.Totals {
|
||||
if total.Label == "Gesamtkosten bei Nichtzulassung" {
|
||||
found = true
|
||||
if total.Total <= 0 {
|
||||
t.Errorf("Nichtzulassung total = %v, should be > 0", total.Total)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("missing 'Gesamtkosten bei Nichtzulassung' total")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateFullLitigation_Nullity(t *testing.T) {
|
||||
fc := NewFeeCalculator()
|
||||
|
||||
req := models.FeeCalculateRequest{
|
||||
Streitwert: 1000000,
|
||||
VATRate: 0,
|
||||
ProceedingPath: models.PathNullity,
|
||||
Instances: []models.InstanceInput{
|
||||
{Type: models.InstanceBPatG, Enabled: true, FeeVersion: "2025", NumAttorneys: 1, NumPatentAttorneys: 1, NumClients: 1, OralHearing: true},
|
||||
{Type: models.InstanceBGHNull, Enabled: true, FeeVersion: "2025", NumAttorneys: 1, NumPatentAttorneys: 1, NumClients: 1, OralHearing: true},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := fc.CalculateFullLitigation(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Instances) != 2 {
|
||||
t.Errorf("got %d instances, want 2", len(resp.Instances))
|
||||
}
|
||||
|
||||
if len(resp.Totals) != 1 || resp.Totals[0].Label != "Gesamtkosten Nichtigkeitsverfahren" {
|
||||
t.Errorf("unexpected totals: %+v", resp.Totals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSchedules(t *testing.T) {
|
||||
fc := NewFeeCalculator()
|
||||
schedules := fc.GetSchedules()
|
||||
|
||||
if len(schedules) != 5 {
|
||||
t.Errorf("got %d schedules, want 5", len(schedules))
|
||||
}
|
||||
|
||||
// Aktuell should be an alias
|
||||
last := schedules[len(schedules)-1]
|
||||
if !last.IsAlias || last.AliasOf != "2025" {
|
||||
t.Errorf("Aktuell should be alias of 2025, got IsAlias=%v AliasOf=%v", last.IsAlias, last.AliasOf)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user